Compare commits

...
34 Commits
Author SHA1 Message Date
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
nicolas df48ee142e Version 3.0.0 2026-11-09 15:03:00 +01:00
nicolas 39a0088ed2 message affiché au joueur quand le serveur n’est pas encore à jour ; à ajouter au commit 5 « Module joueur », ou à faire en commit séparé. 2026-11-09 15:02:39 +01:00
nicolas 3a2d87b726 Documentation : architecture § 11 quinquies (phase 7d) et README 2026-11-08 23:17:18 +01:00
nicolas eeb4818df4 Tests : règles, module serveur, formulaire joueur et page d'administration 2026-11-08 23:17:02 +01:00
nicolas 3772b339e6 Launcher : bouton Aide, fenêtre de demande et affichage de la page d'administration 2026-11-08 23:16:37 +01:00
nicolas a9f5f39f36 Menu d'administration : page « Demandes des joueurs » et carte du tableau de bord 2026-11-08 23:14:59 +01:00
nicolas 86c98eef99 Nouveau projet GameLauncher.Modules.Feedback : formulaire du joueur 2026-11-08 23:14:19 +01:00
nicolas 4bdc627d6b Déploiement : nginx et LISEZMOI pour les demandes des joueurs (étape 3 octies) 2026-11-08 23:13:37 +01:00
nicolas a13b1841f5 Serveur : module Feedback et purge automatique (demandes + journal d'audit) 2026-11-08 23:12:53 +01:00
nicolas efbe9b03f9 Client API : envoi anonyme d'une demande et routes d'administration 2026-11-08 23:11:24 +01:00
nicolas 6207147202 Contrats : formulaires, règles et preuve anti-spam des demandes des joueurs 2026-11-08 23:10:49 +01:00
nicolas c9a491622b Merge pull request 'Dev/2.5.0' (#125) from dev/2.5.0 into master
Reviewed-on: #125
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-08 15:55:01 +01:00
nicolas c210754a18 Version 2.5.0 2026-11-08 15:46:22 +01:00
nicolas 389593a3b4 Plein écran : fenêtre agrandie sur la zone utile de l'écran, barre des tâches toujours visible 2026-11-08 15:28:20 +01:00
nicolas 22ee3a6dee Documentation
Message : Docs : plein écran, règles responsive pour les futurs modules, nom de l'appareil dans l'audit
2026-11-08 15:22:18 +01:00
nicolas a72761ccd0 Boîtes de dialogue : taille adaptée à l’écran de la fenêtre parente
Message : Dialogues : taille limitée à l'écran de la fenêtre parente, message long qui défile
2026-11-08 15:21:52 +01:00
nicolas 62011342ec Menu d’administration : plein écran et responsive
Message : Menu admin : plein écran, bandeaux pleine largeur, pages en deux colonnes qui défilent sur petit écran
2026-11-08 15:21:13 +01:00
nicolas 0f93b784f5 Fenêtre principale : plein écran et responsive
Message : Fenêtre principale : bouton plein écran, colonnes fluides, présentation du jeu qui défile
2026-11-08 15:20:44 +01:00
nicolas 3ad27965fd Plein écran : socle réutilisable
Message : Plein écran : comportement WindowModeAssist (F11, mode mémorisé), mise en page responsive ScaleToFitDecorator
2026-11-08 15:20:17 +01:00
nicolas 69e710d3b7 Audit : nom de l’appareil défini à l’invitation au lieu du nom Windows
Message : Audit : afficher le nom de l'appareil choisi à l'invitation, ne plus envoyer le nom Windows du PC
2026-11-08 15:19:04 +01:00
fred d8219acaab Merge pull request 'Dev/2.4.1' (#122) from dev/2.4.1 into master
Reviewed-on: #122
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-07 20:59:39 +01:00
83 changed files with 7957 additions and 253 deletions

No files matched your search

+11
View File
@@ -68,6 +68,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules", "src
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Admin", "src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj", "{E4FD522A-F44B-4401-9A18-542D42136E6B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GameLauncher.Modules.Feedback", "src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj", "{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -164,6 +166,14 @@ Global
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|Any CPU.Build.0 = Release|Any CPU
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.ActiveCfg = Release|x64
{E4FD522A-F44B-4401-9A18-542D42136E6B}.Release|x64.Build.0 = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.ActiveCfg = Debug|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Debug|x64.Build.0 = Debug|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|Any CPU.Build.0 = Release|Any CPU
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.ActiveCfg = Release|x64
{6A0C2E54-3B1F-4D8A-9E27-5C4B7F1D8E36}.Release|x64.Build.0 = Release|x64
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B7E3C2A-9D41-4F6B-8A2E-1C3D5F7A9B0C}.Debug|x64.ActiveCfg = Debug|x64
@@ -189,6 +199,7 @@ Global
{1F9FBD8B-7A86-4F7B-A2ED-E18F5B014CDF} = {E23AAB8F-1D5C-41C9-AFA3-4246B035CD4C}
{9EBFA383-7928-46F6-8958-F4B38287E380} = {A93779E6-3B93-42B8-B256-5FBC273E7C28}
{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}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
+37 -4
View File
@@ -39,6 +39,7 @@ Vous obtenez un `GameLauncher.exe` autonome (aucun runtime à installer) + `laun
| `src/GameLauncher.ApiClient` | Client de la Launcher API : connexion par clé, requêtes signées, fichier de clé chiffré |
| `src/GameLauncher.Modules` | Socle des modules du launcher (sans WPF) : contrat d'un module, pages du menu d'administration, MVVM commun |
| `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) |
| `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`) |
@@ -52,13 +53,15 @@ src/GameLauncher/
├─ 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 »)
├─ Views/ MainWindow (+ liste des jeux), InstallDialog, SettingsWindow, ChangelogWindow, ModernDialog, TrayMenuWindow
├─ 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)
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration), interfaces
│ déjà ouvert), GameScopedServices, AdminWindowService (menu d'administration),
│ FeedbackWindowService (bouton « Aide »), interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF), TrayIcon (zone de notification),
ModuleDialogs, WpfUiDispatcher (services offerts aux modules)
```
@@ -288,6 +291,16 @@ Deux fenêtres distinctes :
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
sont repris de ceux du jeu affiché par défaut.
### Plein écran
La fenêtre principale et le menu d'administration ont deux modes : **fenêtré** (taille d'origine) ou
**plein écran** (fenêtre agrandie sur tout l'écran, la barre des tâches restant visible). Bascule : bouton entre « Réduire » et
« Fermer », ou **F11**. Le mode de chaque fenêtre est retenu pour la fois suivante. Les fenêtres ne se
redimensionnent jamais par les bords ou les coins ; la mise en page s'adapte à la place disponible
(plein écran : colonnes plus larges ; petit écran ou mise à l'échelle 125-150 % : contenu réduit d'un
bloc, sans rien rogner). Les autres fenêtres (paramètres, notes de version agrandies, boîtes de
dialogue) gardent leur taille fixe.
### Zone de notification (icônes cachées, près de l'horloge)
Deux options des *Paramètres du launcher* (désactivées par défaut) :
@@ -312,6 +325,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
├─ self-update.json mise à jour du launcher (§7)
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
```
@@ -497,7 +511,8 @@ Phase 2 livrée : le **menu d'administration** du launcher (connexion, invitatio
Phase 3 livrée : la page **Jeux** (catalogue des jeux, voir plus bas). Phase 4 livrée : la page
**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ée ensuite : la page **Fichiers du serveur**.
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).
* **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
@@ -505,6 +520,22 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
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.
* **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`. **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 « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
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é,
vérifiée par le serveur, protège l'administration.
@@ -595,13 +626,15 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
* **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
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, IP, clé…). La période est demandée au
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, appareil, IP, clé…). La période est demandée au
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
Chaque entrée indique l'**appareil** sous le nom choisi à l'enregistrement de sa clé (« J'ai une
invitation »), jamais le nom Windows du PC (colonne « Appareil » de l'export CSV).
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
+165 -3
View File
@@ -67,6 +67,7 @@ Conséquences :
| `src/GameLauncher` | net8.0-windows (WPF) | Application ; hôte des modules ; **affichage** des modules (vues WPF du menu d'administration : `Views/Admin`, `Themes/Admin.xaml`) |
| `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.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é |
@@ -77,8 +78,8 @@ Conséquences :
Dépendances : `Contracts` ne dépend de rien ; `ApiClient` → `Contracts` ; `Server` → `Contracts`
(uniquement : le serveur n'utilise pas `Core`) ; `Core` → `Contracts` (règles du catalogue, phase 3) ;
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `GameLauncher` → `Core`, `Modules`,
`Modules.Admin`.
`Modules` → `Core`, `ApiClient` ; `Modules.Admin` → `Modules` ; `Modules.Feedback` → `Modules` ;
`GameLauncher` → `Core`, `Modules`, `Modules.Admin`, `Modules.Feedback`.
Aucun paquet NuGet n'est nécessaire en dehors du projet de tests (xUnit).
## 5. Modules
@@ -139,6 +140,48 @@ public sealed record AdminPanelDescriptor(
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
### 5.3 Affichage : mode fenêtré ou plein écran, mise en page responsive (✅)
Deux modes seulement, pour la fenêtre principale et le menu d'administration (les autres fenêtres —
paramètres, notes agrandies, boîtes de dialogue — gardent leur taille fixe) : **fenêtré** (taille
d'origine, jamais redimensionnable par les bords ou les coins) et **plein écran** au sens d'une fenêtre
Windows agrandie (toute la zone utile de l'écran où se trouve la fenêtre, la barre des tâches restant visible,
même en masquage automatique ; pas de plein écran exclusif comme un jeu). Bascule : bouton entre « Réduire » et « Fermer », ou **F11**.
Le mode est mémorisé par fenêtre (`%AppData%\…\GameLauncher\window-modes.json`, ex.
`{ "main": true, "admin": false }` ; simple préférence, sans donnée personnelle).
* `Controls/WindowModeAssist` (propriétés attachées, aucun code dans la fenêtre) :
`ui:WindowModeAssist.AllowFullScreen="True"` + `ui:WindowModeAssist.PersistenceKey="clé"` sur la
fenêtre ; bouton `<Button Style="{StaticResource WindowModeButtonStyle}" />` (icône et infobulle
selon le mode, masqué si la fenêtre ne le permet pas). En plein écran, les ressources de la fenêtre
nommées `WindowChrome.*` (CornerRadius, Thickness : arrondis, marge de l'ombre) passent à 0 ; elles
sont utilisées en `DynamicResource`. En mode fenêtré, la taille est limitée à la zone utile de
l'écran de la fenêtre. Déplacement par la barre de titre : `WindowModeAssist.DragMove(window)` (sans
effet en plein écran) ; réaffichage d'une fenêtre réduite : `WindowModeAssist.RestoreFromMinimized`
(jamais `WindowState = Normal`, qui ferait quitter le plein écran).
* `Controls/ScaleToFitDecorator` (responsive) : `DesignWidth` × `DesignHeight` = taille du contenu
en mode fenêtré. Plus de place (plein écran) : le contenu s'étale (colonnes fluides, listes et
ascenseurs) ; moins de place (petit écran, mise à l'échelle Windows 125-150 %) : il est mis en page
à cette taille puis réduit d'un bloc. Rien n'est jamais rogné : tous les boutons restent
accessibles, tous les textes visibles. Le contenu n'est jamais agrandi.
* **Nouvelle page d'administration** (module futur) : elle s'affiche dans la fenêtre d'administration
et profite donc du plein écran sans rien déclarer. Règles de mise en page pour qu'elle reste
lisible dans les deux modes :
* ne pas fixer la largeur d'un bloc de texte (`MaxWidth` sur un bandeau…) : texte en
`TextWrapping="Wrap"` dans un `DockPanel` ou une colonne `*` d'un `Grid`, jamais dans un
`StackPanel` horizontal (il ne revient alors jamais à la ligne) ;
* listes de tuiles : `WrapPanel` ; libellés sur une ligne : `TextTrimming="CharacterEllipsis"` +
infobulle ;
* colonnes de largeur fixe : seulement pour une liste de navigation (≤ 260) ; la partie
principale en `*` ;
* page en deux colonnes qui défilent séparément : `HasSplitLayout => true` ; la page reçoit la
hauteur visible (au moins 360 px, sinon la page entière défile en plus) ;
* bandeaux d'erreur et de résultat : reprendre ceux des pages existantes (`AdminErrorBannerStyle`,
`AdminInfoBannerStyle`, icône à gauche dans un `DockPanel`).
* **Futur module du launcher** affiché dans sa propre fenêtre : `AllowFullScreen` seulement si la fenêtre
contient assez d'informations pour le mériter, avec une `PersistenceKey` propre et un
`ScaleToFitDecorator` autour du contenu.
## 6. Authentification par clé publique/privée
### 6.1 Clés
@@ -279,6 +322,12 @@ premier démarrage ou à la migration d'`admins.json` (format 1 → 2) avec les
| `changelog.edit` | ✔ | ✔ | ✔ | Notes de version (phase 4) |
| `releases.publish` | ✔ | ✔ | | Publication d'une version (phase 5) |
| `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 |
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
d'un propriétaire, page « Groupes et permissions »). Les groupes par défaut créés **après** l'ajout la reçoivent.
Les permissions sont des chaînes « module.action ». **Intégrées** : déclarées dans le code
(`AdminPermissions.All`), vérifiées par les routes du serveur et les pages du launcher, jamais supprimables.
@@ -562,6 +611,15 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* **Appareil** : chaque entrée liée à une clé affiche le **nom de l'appareil choisi à l'enregistrement de
la clé** (formulaire « J'ai une invitation »), jamais le nom Windows du PC. Le serveur l'ajoute à la
lecture (`AuditEntry.KeyLabel`, d'après `admins.json`) : il n'est jamais écrit dans les fichiers du
journal (ajout seul), et les anciennes entrées en profitent aussi ; colonne « Appareil » dans l'export
CSV, recherche comprise. À la connexion (`auth.login`), le détail est « appareil « Nom » · GameLauncher
x.y.z » : le launcher n'envoie plus le nom Windows du PC (`LoginRequest.ClientName` = logiciel et
version) et le serveur retire celui qu'ajoutaient les launchers jusqu'à la version 2.4.1 (il n'est ni
journalisé ni conservé dans la session). Les lignes déjà écrites par un ancien serveur gardent leur texte
d'origine (journal en ajout seul).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
@@ -621,6 +679,109 @@ Page « Jeux » › icône ou image de fond › **« Parcourir… »** (explorat
adresse figurant dans le catalogue signé, l'image est couverte par cette signature. Les images inutilisées se
suppriment depuis « Fichiers du serveur ».
### 11 quinquies. Demandes des joueurs : bouton « Aide » (7d ✅)
**Besoin** : depuis le launcher, un joueur envoie, **sans compte**, un **avis**, une **demande d'évolution**, un **bug**
ou **autre chose**, rattaché au **jeu sélectionné** ; les administrateurs les lisent par jeu dans le menu
d'administration, sans pouvoir répondre (un futur module « comptes » permettra l'échange ; les demandes anonymes
actuelles resteront sans propriétaire : aucun secret local n'est conservé pour les rattacher plus tard).
**Formulaires partagés** (`Contracts/Feedback`) : `FeedbackForms` décrit une fois chaque type et ses champs
prédéfinis (texte, choix, note 1 à 5 ; obligatoires ou non) ; le launcher génère le formulaire, le serveur valide
avec `FeedbackRules` (mêmes règles des deux côtés), le menu affiche les libellés. Titre 5 à 120 caractères, textes
2 000 caractères / 40 lignes, total 8 000, corps 16 Ko ; caractères de contrôle, invisibles, de mise en forme,
d'inversion du sens d'écriture, d'usage privé et demi-caractères refusés ; fins de ligne normalisées.
**Route anonyme** (première route d'écriture ouverte à tous ; module serveur `Feedback`) :
| 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 (+ 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 |
Défenses, dans l'ordre de traitement : réception désactivée par défaut (`LauncherApi:Feedback:Enabled`, 503) ;
limite de débit ASP.NET (`feedback`, 10/min par IP) et nginx (`feedback_par_ip`, 6/min) ; corps 16 Ko ; **preuve de
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, 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` (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. 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. 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
(`feedback.purge`) ; **journal d'audit** : fichiers mensuels au-delà de `Audit:RetentionMonths` (12) supprimés
(`audit.purge`) — corrige l'écart relevé par la fiche RGPD.
**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). 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).
**Menu d'administration** : module `FeedbackAdminModule`, page **« Demandes des joueurs »** (`feedback.read`) : choix
du jeu (tuiles avec compteurs ; un seul jeu : choisi d'office), filtres type / statut (« À traiter » par défaut) /
recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais interprété), statut et note
(`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).
## 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 :
@@ -723,6 +884,7 @@ trousseau signé par la racine dont le numéro atteint `keyringMinSequence` ; re
| **7** | Signature de bout en bout (trousseau signé hors ligne par la clé racine, signature des versions et du catalogue à la publication, page « Signature », vérification côté joueur en « warn » puis « require ») | ✅ |
| **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) | ✅ |
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.
@@ -734,5 +896,5 @@ change : le menu d'administration est discret et ne contacte le serveur qu'une f
| Identifiant + mot de passe + OTP | Mots de passe hameçonnables ; secrets OTP stockés sur le serveur |
| Envoi SFTP direct depuis le launcher | Pas de droits par rôle, pas d'audit, pas de validation serveur, clé SFTP = accès total aux fichiers |
| Secret partagé dans l'exe | Extractible par n'importe quel joueur |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde |
| Base de données (SQL) | Inutile à cette échelle : fichiers JSON écrits atomiquement, verrou inter-processus, sauvegarde. SQLite n'est pas exclu : sa légitimité est évaluée **module par module** (voir § 11 quinquies pour les demandes des joueurs) |
| Thème XAML distant | Un XAML téléchargé peut référencer du code : seuls les thèmes intégrés sont sélectionnables |
@@ -31,7 +31,8 @@ public static class ApiModules
new Modules.Changelog.ChangelogModule(),
new Modules.Publishing.PublishingModule(),
new Modules.Signing.SigningModule(),
new Modules.Files.FilesModule()
new Modules.Files.FilesModule(),
new Modules.Feedback.FeedbackModule()
};
}
@@ -46,4 +47,7 @@ public static class RateLimitPolicies
/// à la place de la limite générale (un envoi rapide en enchaîne plusieurs par seconde).
/// </summary>
public const string PublicationUpload = "publication-upload";
/// <summary>Demandes des joueurs : défis et envois anonymes (en plus de la limite générale).</summary>
public const string Feedback = "feedback";
}
@@ -47,6 +47,12 @@ public sealed class LauncherApiOptions
public FilesSettings Files { get; set; } = new();
/// <summary>Demandes des joueurs (bouton « Aide » du launcher) : réception anonyme, consultation dans le menu.</summary>
public FeedbackSettings Feedback { get; set; } = new();
/// <summary>Journal d'audit : durée de conservation (purge automatique).</summary>
public AuditSettings Audit { get; set; } = new();
/// <summary>
/// Jeux dont le manifest.json publié peut être modifié par l'API (notes de version, phase 4), par
/// identifiant de jeu (GameId de launcher.json). Absent : notes non modifiables en ligne.
@@ -119,6 +125,58 @@ public sealed class LauncherApiOptions
public string Root { get; set; } = "";
}
/// <summary>
/// Demandes des joueurs (avis, évolutions, bugs…), envoyées SANS compte depuis le bouton « Aide » du launcher.
/// Route anonyme : preuve de travail, limites par adresse IP (en mémoire uniquement) et plafonds globaux. Aucune
/// adresse IP n'est enregistrée avec une demande ni dans l'audit de son envoi.
/// </summary>
public sealed class FeedbackSettings
{
/// <summary>Réception activée (faux par défaut : les routes répondent 503 « not_configured »).</summary>
public bool Enabled { get; set; }
/// <summary>
/// Identifiants des jeux acceptés (GameId de launcher.json). Vide : les jeux de <see cref="LauncherApiOptions.Games"/>.
/// Une demande pour un autre jeu est refusée.
/// </summary>
public string[] Games { get; set; } = Array.Empty<string>();
/// <summary>Difficulté du défi anti-spam (bits à zéro) : 18 ≈ une fraction de seconde sur un PC. À relever en cas d'attaque.</summary>
public int ProofDifficulty { get; set; } = 18;
/// <summary>Durée de validité d'un défi.</summary>
public int ChallengeLifetimeSeconds { get; set; } = 600;
/// <summary>Demandes acceptées par adresse IP (IPv6 : par /64) sur 10 minutes, et sur 24 heures.</summary>
public int PerIpPer10Minutes { get; set; } = 3;
public int PerIpPerDay { get; set; } = 10;
/// <summary>Défis et envois par minute et par adresse IP (limite de débit ASP.NET Core, avant tout traitement).</summary>
public int RequestsPerMinute { get; set; } = 10;
/// <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; } = 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;
/// <summary>Conservation : demande close supprimée N mois après sa clôture ; toute demande au plus M mois après réception.</summary>
public int RetentionClosedMonths { get; set; } = 12;
public int RetentionMaxMonths { get; set; } = 24;
}
/// <summary>Journal d'audit (fichiers audit-AAAA-MM.jsonl).</summary>
public sealed class AuditSettings
{
/// <summary>
/// Mois conservés : un fichier mensuel est supprimé une fois tous ses jours plus anciens que cette durée
/// (CNIL : 6 mois à 1 an pour les journaux de sécurité). Vérifié au démarrage puis chaque jour.
/// </summary>
public int RetentionMonths { get; set; } = 12;
}
/// <summary>Un jeu publié sur ce serveur.</summary>
public sealed class GameSettings
{
@@ -188,6 +246,9 @@ public sealed class LauncherApiOptions
public IReadOnlyDictionary<string, string> GameManifestFiles { get; private set; } =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Jeux acceptés par la réception des demandes des joueurs, calculés par <see cref="Validate"/>.</summary>
public IReadOnlySet<string> FeedbackGameIds { get; private set; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
/// <summary>Vérifie les réglages au démarrage : un serveur mal configuré ne démarre pas.</summary>
public void Validate(string contentRoot)
{
@@ -218,6 +279,7 @@ public sealed class LauncherApiOptions
ValidateKeyring();
ValidateGames();
ValidateFiles();
ValidateFeedback();
Require(Sessions.IdleMinutes is >= 1 and <= 240, "Sessions:IdleMinutes (1 à 240)");
Require(Sessions.MaxHours is >= 1 and <= 24, "Sessions:MaxHours (1 à 24)");
@@ -229,6 +291,33 @@ public sealed class LauncherApiOptions
Limits.PublicationUploadsPerMinute >= 1, "Limits");
Require(Limits.MaxActiveInvitations is >= 1 and <= 1000, "Limits:MaxActiveInvitations");
Require(Limits.MaxRequestBodyBytes is >= 1024 and <= 16 * 1024 * 1024, "Limits:MaxRequestBodyBytes");
Require(Audit.RetentionMonths is >= 1 and <= 120, "Audit:RetentionMonths (1 à 120)");
}
private void ValidateFeedback()
{
var feedback = Feedback;
Require(feedback.ProofDifficulty is >= Contracts.Feedback.FeedbackProof.MinDifficulty and <= Contracts.Feedback.FeedbackProof.MaxDifficulty,
$"Feedback:ProofDifficulty ({Contracts.Feedback.FeedbackProof.MinDifficulty} à {Contracts.Feedback.FeedbackProof.MaxDifficulty})");
Require(feedback.ChallengeLifetimeSeconds is >= 60 and <= 3600, "Feedback:ChallengeLifetimeSeconds (60 à 3600)");
Require(feedback.PerIpPer10Minutes is >= 1 and <= 1000 && feedback.PerIpPerDay is >= 1 and <= 10_000 &&
feedback.RequestsPerMinute is >= 1 and <= 10_000, "Feedback : limites par adresse IP");
Require(feedback.MaxPerGamePerDay is >= 1 and <= 100_000, "Feedback:MaxPerGamePerDay");
Require(feedback.MaxPerGame is >= 1 and <= 50_000, "Feedback:MaxPerGame (1 à 50000)");
Require(feedback.MaxTotalMegabytes is >= 1 and <= 10_000, "Feedback:MaxTotalMegabytes");
Require(feedback.MinFreeDiskMegabytes is >= 0 and <= 1_000_000, "Feedback:MinFreeDiskMegabytes");
Require(feedback.RetentionClosedMonths is >= 1 and <= 120, "Feedback:RetentionClosedMonths (1 à 120)");
Require(feedback.RetentionMaxMonths is >= 1 and <= 120 && feedback.RetentionMaxMonths >= feedback.RetentionClosedMonths,
"Feedback:RetentionMaxMonths (1 à 120, au moins RetentionClosedMonths)");
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var id in feedback.Games is { Length: > 0 } configured ? configured : Games.Keys.ToArray())
{
if (!Contracts.Catalog.CatalogRules.IsValidGameId(id))
throw new InvalidOperationException($"{Section}:Feedback:Games : identifiant de jeu invalide « {id} ».");
ids.Add(id);
}
FeedbackGameIds = ids;
}
private void ValidateCatalog()
@@ -0,0 +1,80 @@
using GameLauncher.Contracts.Dtos;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Hosting;
/// <summary>
/// Durées de conservation (RGPD, recommandations de la CNIL) appliquées automatiquement, au démarrage du service
/// puis toutes les 24 heures :
/// • journal d'audit : fichiers mensuels plus anciens que <c>LauncherApi:Audit:RetentionMonths</c> (12 mois) ;
/// • demandes des joueurs : closes depuis <c>Feedback:RetentionClosedMonths</c> (12), et toutes au-delà de
/// <c>Feedback:RetentionMaxMonths</c> (24).
/// Chaque purge qui supprime quelque chose est inscrite au journal (« audit.purge », « feedback.purge »). Une erreur est
/// écrite dans journalctl sans arrêter le service ; la purge suivante réessaie.
/// </summary>
public sealed class RetentionService : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
private readonly AuditLog _audit;
private readonly FeedbackStore _feedback;
private readonly LauncherApiOptions _options;
private readonly TimeProvider _time;
private readonly ILogger<RetentionService> _logger;
public RetentionService(AuditLog audit, FeedbackStore feedback, IOptions<LauncherApiOptions> options, TimeProvider time,
ILogger<RetentionService> logger)
{
_audit = audit;
_feedback = feedback;
_options = options.Value;
_time = time;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
RunOnce();
try
{
await Task.Delay(Interval, stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
}
}
/// <summary>Une passe de purge (appelée aussi par les tests).</summary>
public void RunOnce()
{
try
{
var months = _audit.Purge(_options.Audit.RetentionMonths);
if (months.Count > 0)
_audit.Write("audit.purge", AuditResults.Ok,
details: $"{months.Count} mois supprimé(s) ({string.Join(", ", months)}) ; conservation {_options.Audit.RetentionMonths} mois");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge du journal d'audit impossible");
}
try
{
var removed = _feedback.Purge(_time.GetUtcNow());
if (removed > 0)
_audit.Write("feedback.purge", AuditResults.Ok,
details: $"{removed} demande(s) supprimée(s) ; conservation {_options.Feedback.RetentionClosedMonths} mois après clôture, " +
$"{_options.Feedback.RetentionMaxMonths} mois au plus");
}
catch (Exception ex)
{
_logger.LogError(ex, "Purge des demandes des joueurs impossible");
}
}
}
@@ -84,6 +84,8 @@ public static class ServerApp
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.AuthPerMinute, TimeSpan.FromMinutes(1))));
limiter.AddPolicy(RateLimitPolicies.Enrollment, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Limits.EnrollmentsPerHour, TimeSpan.FromHours(1))));
limiter.AddPolicy(RateLimitPolicies.Feedback, context =>
RateLimitPartition.GetFixedWindowLimiter(context.RateLimitPartition(), _ => Window(options.Feedback.RequestsPerMinute, TimeSpan.FromMinutes(1))));
});
// Protection des données d'ASP.NET Core (cookies, jetons anti-falsification) : inutilisée par l'API,
@@ -98,6 +100,8 @@ public static class ServerApp
builder.Services.AddSingleton<AdminStore>();
builder.Services.AddAdminAuthentication();
foreach (var module in ApiModules.All) module.ConfigureServices(builder.Services, options);
// Conservation (RGPD) : purge du journal d'audit et des demandes des joueurs, au démarrage puis chaque jour.
builder.Services.AddHostedService<RetentionService>();
var app = builder.Build();
@@ -29,7 +29,8 @@ public sealed class AuditModule : IApiModule
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
}
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations)
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations,
AdminStore store)
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
DateTimeOffset? until = null;
@@ -43,7 +44,7 @@ public sealed class AuditModule : IApiModule
until = parsed;
}
var page = audit.Read(limit ?? 50, cursor, until);
var page = WithDeviceNames(audit.Read(limit ?? 50, cursor, until), store);
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
@@ -61,6 +62,25 @@ public sealed class AuditModule : IApiModule
return Results.Json(page, ApiJson.Options);
}
/// <summary>
/// Nom de l'appareil de chaque clé (défini à l'enregistrement de la clé, avec l'invitation), ajouté à la lecture :
/// les anciennes entrées en profitent aussi, et le journal sur le disque reste inchangé (ajout seul).
/// </summary>
internal static AuditPage WithDeviceNames(AuditPage page, AdminStore store)
{
if (page.Entries.All(e => e.KeyId is null && e.KeyLabel is null)) return page;
var database = store.Read();
var labels = new Dictionary<string, string?>(StringComparer.Ordinal);
var entries = page.Entries.Select(entry =>
{
if (entry.KeyId is not { } keyId) return entry with { KeyLabel = null };
if (!labels.TryGetValue(keyId, out var label))
labels[keyId] = label = database.FindKey(keyId) is { } found ? Auth.AuthModule.DeviceName(found.Key.Label) : null;
return entry with { KeyLabel = label }; // seul le serveur renseigne ce champ (jamais lu depuis le fichier)
}).ToList();
return page with { Entries = entries };
}
private static IResult RecordExport(HttpContext context, AuditLog audit)
{
AuditExportRequest? request;
@@ -97,7 +97,7 @@ public sealed class AuthModule : IApiModule
return ApiResults.Unauthorized(LoginRefused);
}
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
var clientName = ClientSoftware(ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength));
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
// admins.json à chaque connexion).
@@ -117,7 +117,10 @@ public sealed class AuthModule : IApiModule
}
}
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
// Appareil : le nom choisi à l'enregistrement de la clé (invitation), jamais le nom Windows du PC.
var device = $"appareil « {DeviceName(key.Label)} »";
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip,
details: clientName is null ? device : $"{device} · {clientName}");
var database = store.Read();
var authenticated = new AuthenticatedAdmin(session, admin, key, database.PermissionsOf(admin.Role), database.GroupName(admin.Role));
return Results.Json(new LoginResponse
@@ -130,6 +133,23 @@ public sealed class AuthModule : IApiModule
}, ApiJson.Options);
}
/// <summary>Nom de l'appareil tel que défini à l'enregistrement de la clé (invitation).</summary>
internal static string DeviceName(string? keyLabel) => string.IsNullOrWhiteSpace(keyLabel) ? "sans nom" : keyLabel;
/// <summary>
/// Logiciel client (« GameLauncher 2.5.0 »), pour le journal. Les launchers jusqu'à la version 2.4.1 y ajoutaient le
/// nom Windows du PC entre parenthèses (« GameLauncher 2.4.1 (DESKTOP-ABC123) ») : il est retiré, seul le nom de
/// l'appareil choisi à l'enregistrement de la clé identifie le poste (et le nom Windows n'est pas conservé).
/// </summary>
internal static string? ClientSoftware(string? clientName)
{
if (clientName is null) return null;
if (clientName.StartsWith("GameLauncher ", StringComparison.Ordinal) && clientName.EndsWith(')') &&
clientName.IndexOf(" (", StringComparison.Ordinal) is var open and > 0)
return clientName[..open];
return clientName;
}
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
{
var admin = context.Admin();
@@ -0,0 +1,276 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Modules.Feedback;
/// <summary>
/// Demandes des joueurs (bouton « Aide » du launcher : avis, demande d'évolution, bug, autre), rangées par jeu.
/// • Joueurs, SANS compte (routes anonymes, seules routes d'écriture ouvertes à tous) : défi anti-spam à résoudre
/// (<see cref="FeedbackGuard"/>), limites par adresse IP en mémoire, corps de 16 Ko au plus, contrôle complet
/// (<see cref="FeedbackRules"/>), plafonds globaux (<see cref="FeedbackStore"/>). Ni l'adresse IP ni aucun
/// 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 »).
/// </summary>
public sealed class FeedbackModule : IApiModule
{
public string Name => "Feedback";
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<FeedbackStore>();
services.AddSingleton<FeedbackGuard>();
services.AddSingleton<FeedbackConsultations>();
}
public void MapEndpoints(IEndpointRouteBuilder api)
{
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.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();
}
private static IResult NotConfigured() => ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"La réception des demandes n'est pas activée sur le serveur.");
// ------------------------------------------------------------------ Joueurs (anonymes)
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)
{
if (!store.Enabled) return NotConfigured();
FeedbackSubmission? submission;
try
{
submission = JsonSerializer.Deserialize<FeedbackSubmission>(context.CapturedBody(), ApiJson.Options);
}
catch (JsonException)
{
return ApiResults.BadRequest("Demande illisible.");
}
// 1. Défi anti-spam (une empreinte à calculer) : avant tout autre traitement.
if (submission is null || !guard.TryConsume(submission.ChallengeId, submission.Solution))
{
audit.WriteAnonymousFailure("feedback.submit", null, null, "défi anti-spam absent, expiré, déjà utilisé ou mal résolu");
return ApiResults.Error(400, ApiErrorCodes.ProofInvalid, "Vérification anti-spam échouée : réessayez.");
}
// 2. Jeu accepté par ce serveur (identifiant de la configuration : seul celui-ci sert à construire un chemin).
var gameId = store.ResolveGame(submission.GameId);
if (gameId is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, "jeu non accepté");
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
}
// 3. Contenu (mêmes règles que le launcher).
var errors = new List<string>();
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, errors);
if (clean is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
return ApiResults.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
}
// 4. Limites par adresse IP (en mémoire uniquement), puis plafonds globaux.
if (!guard.TryCountSubmission(context.RateLimitPartition(), out var retryAfter))
{
context.Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(CultureInfo.InvariantCulture);
return ApiResults.Error(429, ApiErrorCodes.RateLimited,
"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);
if (record is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
return ApiResults.Error(503, ApiErrorCodes.FeedbackFull,
"Le service des demandes est momentanément saturé. Réessayez plus tard.");
}
// 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") +
(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);
}
// ------------------------------------------------------------------ Administrateurs
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
{
var now = time.GetUtcNow();
var counts = store.Counts();
return Results.Json(new FeedbackOverview
{
Enabled = store.Enabled,
StorageFull = store.Enabled && counts.Any(c => store.FullReason(c.GameId, now) is not null),
Games = counts,
RetentionClosedMonths = options.Value.Feedback.RetentionClosedMonths,
RetentionMaxMonths = options.Value.Feedback.RetentionMaxMonths
}, ApiJson.Options);
}
private static IResult List(string gameId, string? kind, string? status, string? cursor, int? limit, HttpContext context,
FeedbackStore store, AuditLog audit, FeedbackConsultations consultations)
{
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 (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
var page = store.List(game, kind, status, cursor, Math.Clamp(limit ?? 100, 1, 200));
var actor = context.Admin();
if (consultations.ShouldRecord(actor.Key.KeyId, "list:" + game, continuation: !string.IsNullOrEmpty(cursor)))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"jeu {game}", $"liste : {page.Items.Count} demande(s) sur {page.Total}");
return Results.Json(page, ApiJson.Options);
}
private static IResult Get(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit,
FeedbackConsultations consultations)
{
var game = store.ResolveGame(gameId);
if (game is null || store.Find(game, id) is not { } record) return ApiResults.NotFound("Demande introuvable.");
var actor = context.Admin();
// Lecture d'une demande (texte libre : données personnelles possibles) inscrite au journal, une fois par
// 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)}");
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)
{
var game = store.ResolveGame(gameId);
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
if (!FeedbackStatuses.IsValid(request.Status)) return ApiResults.BadRequest("Statut inconnu.");
var note = FeedbackRules.CleanAdminNote(request.AdminNote, out var noteValid);
if (!noteValid)
return ApiResults.BadRequest($"Note interne : {FeedbackRules.MaxAdminNoteLength} caractères au plus, sans caractère invisible.");
var actor = context.Admin();
var updated = store.Update(game, id, request.Revision, request.Status, note, actor.Admin.DisplayName, time.GetUtcNow(), out var previous);
var changes = new List<string>();
if (previous.Status != updated.Status)
changes.Add($"statut : {FeedbackStatuses.Label(previous.Status)} → {FeedbackStatuses.Label(updated.Status)}");
if (previous.AdminNote != updated.AdminNote) changes.Add(updated.AdminNote is null ? "note interne retirée" : "note interne modifiée");
audit.Write("feedback.update", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
updated.Reference, $"jeu {game} ; " + (changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes)));
return Results.Json(updated.ToDetail(), ApiJson.Options);
}
private static IResult Delete(string gameId, string id, HttpContext context, FeedbackStore store, AuditLog audit)
{
var game = store.ResolveGame(gameId);
if (game is null || !FeedbackRules.IsValidId(id)) return ApiResults.NotFound("Demande introuvable.");
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)}");
return Results.NoContent();
}
}
/// <summary>
/// Dernière consultation inscrite par clé et par élément (liste d'un jeu, demande) : une ligne d'audit par consultation,
/// pas une par page ni par réaffichage dans les 10 minutes.
/// </summary>
public sealed class FeedbackConsultations
{
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
private readonly TimeProvider _time;
private readonly Dictionary<string, DateTimeOffset> _last = new(StringComparer.Ordinal);
public FeedbackConsultations(TimeProvider time) => _time = time;
/// <param name="continuation">Vrai : pas réinscrit si la dernière consultation date de moins de 10 minutes.</param>
public bool ShouldRecord(string keyId, string item, bool continuation)
{
var now = _time.GetUtcNow();
var key = keyId + "|" + item;
lock (_last)
{
if (continuation && _last.TryGetValue(key, out var last) && now - last < Window && now >= last) return false;
if (_last.Count > 20_000) _last.Clear(); // seules les clés enregistrées arrivent ici
_last[key] = now;
return true;
}
}
}
@@ -0,0 +1,178 @@
using System.Buffers.Binary;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Storage;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Security;
/// <summary>
/// Protections de la route ANONYME d'envoi des demandes des joueurs :
/// • défis anti-spam SANS ÉTAT (échéance, difficulté et nonce scellés par HMAC, secret en mémoire) : en demander ne
/// coûte rien au serveur ; seuls les défis UTILISÉS sont mémorisés jusqu'à leur échéance (refus du rejeu) ;
/// • limites par adresse IP (IPv6 : par /64) sur 10 minutes et sur 24 heures, comptées en mémoire uniquement :
/// aucune adresse n'est écrite sur le disque ni rattachée à une demande.
/// </summary>
public sealed class FeedbackGuard
{
private const int NonceLength = 16;
private const int MacLength = 16;
private const int PayloadLength = 8 + 1 + NonceLength; // échéance + difficulté + nonce
private const int MaxTracked = 100_000;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Challenge/v1\n");
private readonly byte[] _secret = RandomNumberGenerator.GetBytes(32);
private readonly ConcurrentDictionary<string, DateTimeOffset> _used = new(StringComparer.Ordinal);
private readonly Dictionary<string, List<DateTimeOffset>> _accepted = new(StringComparer.Ordinal);
private readonly LauncherApiOptions.FeedbackSettings _settings;
private readonly TimeProvider _time;
public FeedbackGuard(IOptions<LauncherApiOptions> options, TimeProvider time)
{
_settings = options.Value.Feedback;
_time = time;
}
public FeedbackChallenge CreateChallenge()
{
var expires = _time.GetUtcNow() + TimeSpan.FromSeconds(_settings.ChallengeLifetimeSeconds);
var payload = new byte[PayloadLength];
BinaryPrimitives.WriteInt64BigEndian(payload, expires.ToUnixTimeSeconds());
payload[8] = (byte)_settings.ProofDifficulty;
RandomNumberGenerator.Fill(payload.AsSpan(9));
var id = new byte[PayloadLength + MacLength];
payload.CopyTo(id, 0);
Mac(payload).CopyTo(id, PayloadLength);
return new FeedbackChallenge
{
ChallengeId = Base64Url.Encode(id),
Nonce = Base64Url.Encode(payload.AsSpan(9)),
Difficulty = payload[8],
ExpiresAt = DateTimeOffset.FromUnixTimeSeconds(expires.ToUnixTimeSeconds())
};
}
/// <summary>
/// Vérifie le défi (sceau, échéance), puis la solution, puis le marque comme utilisé. Faux : défi absent, forgé,
/// expiré, mal résolu ou déjà utilisé.
/// </summary>
public bool TryConsume(string? challengeId, string? solution)
{
if (challengeId is not { Length: > 0 and <= 64 } || !Base64Url.TryDecode(challengeId, out var id) ||
id.Length != PayloadLength + MacLength)
return false;
var payload = id.AsSpan(0, PayloadLength);
if (!CryptographicOperations.FixedTimeEquals(Mac(payload), id.AsSpan(PayloadLength))) return false;
var now = _time.GetUtcNow();
var expires = DateTimeOffset.FromUnixTimeSeconds(BinaryPrimitives.ReadInt64BigEndian(payload[..8]));
if (expires <= now) return false;
// Difficulté scellée à la création du défi (un défi obtenu juste avant un relèvement de la difficulté reste
// valable jusqu'à son échéance) ; jamais en dessous du minimum.
int difficulty = payload[8];
if (difficulty < FeedbackProof.MinDifficulty) return false;
if (!FeedbackProof.Verify(payload[9..], difficulty, solution)) return false;
if (_used.Count >= MaxTracked)
{
foreach (var (key, until) in _used)
if (until <= now) _used.TryRemove(key, out _);
if (_used.Count >= MaxTracked) throw new StoreBusyException("Trop de demandes récentes.");
}
return _used.TryAdd(challengeId, expires);
}
/// <summary>
/// Compte une demande pour cette adresse (partition de limite de débit). Faux si la limite sur 10 minutes ou sur
/// 24 heures est atteinte (rien n'est compté alors). <paramref name="retryAfter"/> : délai avant la prochaine place.
/// </summary>
public bool TryCountSubmission(string partition, out TimeSpan retryAfter)
{
var now = _time.GetUtcNow();
retryAfter = TimeSpan.Zero;
lock (_accepted)
{
if (_accepted.Count >= MaxTracked)
{
foreach (var key in _accepted.Where(p => p.Value.All(t => now - t >= TimeSpan.FromDays(1))).Select(p => p.Key).ToList())
_accepted.Remove(key);
if (_accepted.Count >= MaxTracked) throw new StoreBusyException("Trop d'adresses suivies.");
}
if (!_accepted.TryGetValue(partition, out var times)) _accepted[partition] = times = new List<DateTimeOffset>();
times.RemoveAll(t => now - t >= TimeSpan.FromDays(1));
var recent = times.Where(t => now - t < TimeSpan.FromMinutes(10)).OrderBy(t => t).ToList();
if (recent.Count >= _settings.PerIpPer10Minutes)
{
retryAfter = recent[recent.Count - _settings.PerIpPer10Minutes] + TimeSpan.FromMinutes(10) - now;
return false;
}
if (times.Count >= _settings.PerIpPerDay)
{
retryAfter = times.Min() + TimeSpan.FromDays(1) - now;
return false;
}
times.Add(now);
return true;
}
}
// ------------------------------------------------------------------ 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];
Domain.CopyTo(data, 0);
payload.CopyTo(data.AsSpan(Domain.Length));
return HMACSHA256.HashData(_secret, data)[..MacLength];
}
}
@@ -143,6 +143,35 @@ public sealed class AuditLog
details: suppressed > 0 ? $"{details} (+{suppressed} refus identique(s) la minute précédente)" : details);
}
/// <summary>
/// Conservation : supprime les fichiers mensuels dont TOUS les jours sont antérieurs à <paramref name="months"/> mois
/// (le mois en cours et les précédents encore dans la fenêtre sont gardés en entier). Renvoie les mois supprimés.
/// </summary>
public IReadOnlyList<string> Purge(int months)
{
if (months < 1) throw new ArgumentOutOfRangeException(nameof(months));
var now = _time.GetUtcNow().UtcDateTime;
// Premier mois conservé : celui qui contient la date limite (ses derniers jours sont encore dans la fenêtre).
var cutoff = now.AddMonths(-months);
var firstKept = cutoff.ToString("yyyy-MM", CultureInfo.InvariantCulture);
var removed = new List<string>();
if (!Directory.Exists(_directory)) return removed;
lock (_writeLock)
{
using var fileLock = SecureFiles.AcquireLock(Path.Combine(_directory, "audit.lock"), TimeSpan.FromSeconds(5));
foreach (var file in Directory.GetFiles(_directory, "audit-*.jsonl"))
{
var month = Path.GetFileName(file)["audit-".Length..^".jsonl".Length];
if (!DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _)) continue;
if (string.CompareOrdinal(month, firstKept) >= 0) continue;
File.Delete(file);
removed.Add(month);
}
}
removed.Sort(StringComparer.Ordinal);
return removed;
}
/// <summary>
/// Entrées les plus récentes d'abord. <paramref name="cursor"/> : valeur NextCursor de la page
/// précédente (« aaaa-MM:position »). <paramref name="before"/> : seulement les entrées antérieures
@@ -0,0 +1,528 @@
using System.Globalization;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>Demande enregistrée (un fichier JSON par demande, dans les données privées).</summary>
public sealed record FeedbackRecord
{
public const string FormatName = "gamelauncher-feedback";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
public string Id { get; init; } = "";
public string Reference { get; init; } = "";
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { 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; }
public DateTimeOffset? StatusChangedAt { get; init; }
public string? StatusChangedBy { get; init; }
public FeedbackSummary ToSummary() => new()
{
Id = Id,
Reference = Reference,
GameId = GameId,
Kind = Kind,
Title = Title,
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
},
HasNote = !string.IsNullOrEmpty(AdminNote),
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
AttachmentCount = Attachments.Count,
Revision = Revision
};
public FeedbackDetail ToDetail() => new()
{
Summary = ToSummary(),
FormVersion = FormVersion,
Fields = Fields,
Tech = Tech,
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
AdminNote = AdminNote,
StatusChangedBy = StatusChangedBy
};
}
/// <summary>
/// Demandes des joueurs : <c>feedback/&lt;jeu&gt;/&lt;AAAA-MM&gt;/&lt;id&gt;.json</c> dans le dossier des données privées
/// (0700, fichiers 0600, jamais servi par nginx). Un fichier par demande : écriture atomique (fichier temporaire puis
/// renommage) d'une seule demande à la fois, suppression et purge par simple suppression de fichiers. Un index en
/// mémoire (résumés) est construit au premier accès : les listes ne relisent pas le disque. Seul le service écrit
/// ici (l'outil en ligne de commande n'y touche pas) : un verrou en mémoire suffit.
///
/// Choix (au lieu de SQLite) : volumes bornés (<see cref="LauncherApiOptions.FeedbackSettings.MaxPerGame"/>), aucune
/// relation entre données, pas de dépendance native à maintenir sur le VPS, sauvegarde par simple copie de dossier,
/// cohérent avec les autres stores du serveur. À réévaluer avec le futur module « comptes + échanges ».
/// </summary>
public sealed class FeedbackStore
{
private static readonly JsonSerializerOptions Json = new(ApiJson.Options) { WriteIndented = true };
private readonly string _root;
private readonly LauncherApiOptions.FeedbackSettings _settings;
private readonly IReadOnlySet<string> _games;
private readonly ILogger<FeedbackStore>? _logger;
private readonly object _lock = new();
private Dictionary<string, Dictionary<string, FeedbackRecord>>? _index;
private long _totalBytes;
public FeedbackStore(IOptions<LauncherApiOptions> options, ILogger<FeedbackStore> logger)
: this(options.Value.DataDirectoryFullPath, options.Value.Feedback, options.Value.FeedbackGameIds, logger) { }
public FeedbackStore(string dataDirectory, LauncherApiOptions.FeedbackSettings settings, IReadOnlySet<string> games,
ILogger<FeedbackStore>? logger = null)
{
_root = Path.Combine(dataDirectory, "feedback");
_settings = settings;
_games = games;
_logger = logger;
}
public bool Enabled => _settings.Enabled;
/// <summary>
/// Identifiant de jeu tel que configuré (casse du réglage), ou null si ce jeu n'accepte pas de demandes.
/// Seul ce résultat sert à construire un chemin (jamais la valeur reçue).
/// </summary>
public string? ResolveGame(string? gameId)
{
if (string.IsNullOrEmpty(gameId) || gameId.StartsWith('.')) return null;
return _games.FirstOrDefault(g => string.Equals(g, gameId, StringComparison.OrdinalIgnoreCase) && !g.StartsWith('.'));
}
public IReadOnlyList<string> Games => _games.Where(g => !g.StartsWith('.')).Order(StringComparer.OrdinalIgnoreCase).ToList();
// ------------------------------------------------------------------ Envoi
/// <summary>Raison d'un refus d'enregistrement pour cause de plafond (null : place disponible).</summary>
public string? FullReason(string gameId, DateTimeOffset now, long extraBytes = 0)
{
lock (_lock)
{
var index = Index();
var game = index.TryGetValue(gameId, out var records) ? records : null;
if (game is not null)
{
if (game.Count >= _settings.MaxPerGame) return $"{game.Count} demandes conservées pour ce jeu (plafond {_settings.MaxPerGame})";
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 + 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)";
return null;
}
}
/// <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)
{
lock (_lock)
{
if (FullReason(gameId, now) is not null) return null;
var index = Index();
if (!index.TryGetValue(gameId, out var game)) index[gameId] = game = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
var existingReferences = index.Values.SelectMany(g => g.Values).Select(r => r.Reference).ToHashSet(StringComparer.Ordinal);
string reference;
do reference = FeedbackRules.NewReference(); while (existingReferences.Contains(reference));
var record = new FeedbackRecord
{
Id = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant(),
Reference = reference,
GameId = gameId,
Kind = submission.Kind,
FormVersion = submission.FormVersion,
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
};
Write(record);
game[record.Id] = record;
return record;
}
}
// ------------------------------------------------------------------ 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()
{
lock (_lock)
{
var index = Index();
return Games.Select(id =>
{
var records = index.TryGetValue(id, out var game) ? game.Values : (IEnumerable<FeedbackRecord>)Array.Empty<FeedbackRecord>();
var list = records.ToList();
return new FeedbackGameCounts
{
GameId = id,
New = list.Count(r => r.Status == FeedbackStatuses.New),
Open = list.Count(r => !FeedbackStatuses.IsClosed(r.Status)),
Total = list.Count
};
}).ToList();
}
}
/// <summary>
/// Demandes d'un jeu, les plus récentes d'abord. <paramref name="cursor"/> : « ticks-id » de la dernière demande
/// de la page précédente (stable même si des demandes arrivent entre deux pages).
/// </summary>
public FeedbackPage List(string gameId, string? kind, string? status, string? cursor, int limit)
{
lock (_lock)
{
var records = Index().TryGetValue(gameId, out var game) ? game.Values : Enumerable.Empty<FeedbackRecord>();
var filtered = records.Where(r => (kind is null || r.Kind == kind) && (status is null || MatchesStatus(r.Status, status)))
.OrderByDescending(r => r.ReceivedAt).ThenByDescending(r => r.Id, StringComparer.Ordinal)
.ToList();
var start = 0;
if (TryParseCursor(cursor, out var ticks, out var id))
{
start = filtered.FindIndex(r => r.ReceivedAt.UtcTicks < ticks ||
(r.ReceivedAt.UtcTicks == ticks && string.CompareOrdinal(r.Id, id) < 0));
if (start < 0) start = filtered.Count;
}
var page = filtered.Skip(start).Take(limit).ToList();
var more = start + page.Count < filtered.Count;
return new FeedbackPage
{
Items = page.Select(r => r.ToSummary()).ToList(),
NextCursor = more && page.Count > 0 ? $"{page[^1].ReceivedAt.UtcTicks}-{page[^1].Id}" : null,
Total = filtered.Count
};
}
}
/// <summary>Filtre de statut : un statut précis, ou « open » (non close) / « closed ».</summary>
public const string OpenFilter = "open";
public const string ClosedFilter = "closed";
public static bool IsValidStatusFilter(string? status) => status is null or OpenFilter or ClosedFilter || FeedbackStatuses.IsValid(status);
private static bool MatchesStatus(string status, string filter) => filter switch
{
OpenFilter => !FeedbackStatuses.IsClosed(status),
ClosedFilter => FeedbackStatuses.IsClosed(status),
_ => status == filter
};
private static bool TryParseCursor(string? cursor, out long ticks, out string id)
{
ticks = 0;
id = "";
if (string.IsNullOrEmpty(cursor) || cursor.Length > 60) return false;
var dash = cursor.IndexOf('-');
if (dash <= 0 || !long.TryParse(cursor[..dash], NumberStyles.None, CultureInfo.InvariantCulture, out ticks)) return false;
id = cursor[(dash + 1)..];
return FeedbackRules.IsValidId(id);
}
public FeedbackRecord? Find(string gameId, string id)
{
if (!FeedbackRules.IsValidId(id)) return null;
lock (_lock)
return Index().TryGetValue(gameId, out var game) && game.TryGetValue(id, out var record) ? record : null;
}
// ------------------------------------------------------------------ Traitement
/// <summary>Statut et note interne ; révision attendue (409 sinon), refus si la demande n'existe plus (404).</summary>
public FeedbackRecord Update(string gameId, string id, long revision, string status, string? note, string actorName, DateTimeOffset now,
out FeedbackRecord previous)
{
lock (_lock)
{
previous = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (supprimée entre-temps ?).");
if (previous.Revision != revision)
throw ApiRejection.Conflict("La demande a été modifiée par un autre administrateur : rechargez-la.", ApiErrorCodes.RevisionConflict);
var updated = previous with
{
Status = status,
AdminNote = note,
Revision = previous.Revision + 1,
StatusChangedAt = now,
StatusChangedBy = actorName
};
Write(updated);
Index()[gameId][id] = updated;
return updated;
}
}
public FeedbackRecord Delete(string gameId, string id)
{
lock (_lock)
{
var record = Find(gameId, id) ?? throw ApiRejection.NotFound("Demande introuvable (déjà supprimée ?).");
DeleteFile(record);
Index()[gameId].Remove(id);
return record;
}
}
/// <summary>
/// Purge (conservation RGPD) : demandes closes depuis plus de <c>RetentionClosedMonths</c>, et toutes celles reçues
/// depuis plus de <c>RetentionMaxMonths</c>. Renvoie le nombre de demandes supprimées.
/// </summary>
public int Purge(DateTimeOffset now)
{
var closedLimit = now.AddMonths(-_settings.RetentionClosedMonths);
var maxLimit = now.AddMonths(-_settings.RetentionMaxMonths);
var removed = 0;
lock (_lock)
{
foreach (var game in Index().Values)
{
var expired = game.Values.Where(r => r.ReceivedAt < maxLimit ||
(FeedbackStatuses.IsClosed(r.Status) && (r.StatusChangedAt ?? r.ReceivedAt) < closedLimit))
.ToList();
foreach (var record in expired)
{
try
{
DeleteFile(record);
game.Remove(record.Id);
removed++;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger?.LogError(ex, "Purge de la demande {Reference} impossible", record.Reference);
}
}
}
RemoveEmptyFolders();
}
return removed;
}
// ------------------------------------------------------------------ Fichiers
private string GameFolder(string gameId) => Path.Combine(_root, gameId);
private string PathOf(FeedbackRecord record) =>
Path.Combine(GameFolder(record.GameId), record.ReceivedAt.UtcDateTime.ToString("yyyy-MM", CultureInfo.InvariantCulture), record.Id + ".json");
private void Write(FeedbackRecord record)
{
if (ResolveGame(record.GameId) != record.GameId || !FeedbackRules.IsValidId(record.Id))
throw new InvalidOperationException("Demande invalide : écriture refusée.");
var path = PathOf(record);
SecureFiles.EnsureDirectory(_root);
SecureFiles.EnsureDirectory(GameFolder(record.GameId));
SecureFiles.EnsureDirectory(Path.GetDirectoryName(path)!);
var oldSize = File.Exists(path) ? new FileInfo(path).Length : 0;
SecureFiles.WriteAllTextAtomic(path, JsonSerializer.Serialize(record, Json), keepBackup: false);
_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;
File.Delete(path);
_totalBytes -= size;
}
private void RemoveEmptyFolders()
{
if (!Directory.Exists(_root)) return;
foreach (var game in Directory.GetDirectories(_root))
foreach (var month in Directory.GetDirectories(game))
{
try
{
if (!Directory.EnumerateFileSystemEntries(month).Any()) Directory.Delete(month);
}
catch (IOException)
{
// dossier en cours d'utilisation : nettoyé à la purge suivante
}
}
}
private long? FreeDiskBytes()
{
try
{
var folder = Directory.Exists(_root) ? _root : Path.GetDirectoryName(_root)!;
return new DriveInfo(Path.GetFullPath(folder)).AvailableFreeSpace; // même usage que les autres stores
}
catch (Exception ex) when (ex is IOException or ArgumentException or UnauthorizedAccessException)
{
return null;
}
}
/// <summary>
/// Index construit au premier accès : lecture de tous les fichiers des jeux configurés (dossiers et fichiers aux noms
/// attendus uniquement, liens symboliques ignorés). Un fichier illisible est signalé et ignoré : il n'empêche ni le
/// démarrage ni la réception.
/// </summary>
private Dictionary<string, Dictionary<string, FeedbackRecord>> Index()
{
if (_index is not null) return _index;
var index = new Dictionary<string, Dictionary<string, FeedbackRecord>>(StringComparer.Ordinal);
long total = 0;
foreach (var gameId in Games)
{
var records = new Dictionary<string, FeedbackRecord>(StringComparer.Ordinal);
index[gameId] = records;
var folder = GameFolder(gameId);
if (!Directory.Exists(folder) || IsLink(folder)) continue;
foreach (var month in Directory.GetDirectories(folder))
{
if (IsLink(month) || !DateTime.TryParseExact(Path.GetFileName(month), "yyyy-MM", CultureInfo.InvariantCulture, DateTimeStyles.None, out _))
continue;
foreach (var file in Directory.GetFiles(month, "*.json"))
{
var id = Path.GetFileNameWithoutExtension(file);
if (!FeedbackRules.IsValidId(id) || IsLink(file)) continue;
try
{
var info = new FileInfo(file);
if (info.Length > 256 * 1024) throw new InvalidDataException("fichier trop volumineux");
var record = JsonSerializer.Deserialize<FeedbackRecord>(File.ReadAllText(file), Json)
?? throw new InvalidDataException("fichier vide");
if (record.Format != FeedbackRecord.FormatName || record.Id != id ||
!string.Equals(record.GameId, gameId, StringComparison.Ordinal))
throw new InvalidDataException("contenu inattendu");
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)
{
_logger?.LogWarning("Demande ignorée (fichier illisible) : {File} ({Message})", file, ex.Message);
}
}
}
}
_totalBytes = total;
return _index = index;
}
private static bool IsLink(string path)
{
try
{
return new FileInfo(path).LinkTarget is not null;
}
catch (IOException)
{
return true;
}
}
}
@@ -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)
{
@@ -51,6 +51,27 @@
"Files": {
"Root": ""
},
// Demandes des joueurs (bouton « Aide » du launcher) : désactivé par défaut. Activer dans appsettings.Production.json
// (voir server/deploy/LISEZMOI.md, étape 3 octies). Games vide = jeux de « Games » ci-dessus.
// Aucune adresse IP n'est enregistrée avec une demande ; les limites par adresse sont comptées en mémoire.
"Feedback": {
"Enabled": false,
"Games": [],
"ProofDifficulty": 18,
"PerIpPer10Minutes": 3,
"PerIpPerDay": 10,
"RequestsPerMinute": 10,
"MaxPerGamePerDay": 300,
"MaxPerGame": 5000,
"MaxTotalMegabytes": 1024,
"MinFreeDiskMegabytes": 500,
"RetentionClosedMonths": 12,
"RetentionMaxMonths": 24
},
// Journal d'audit : mois conservés (purge automatique au démarrage puis chaque jour ; CNIL : 6 mois à 1 an).
"Audit": {
"RetentionMonths": 12
},
"Limits": {
"RequestsPerMinute": 300,
// Morceaux de 4 Mo envoyés pendant une publication (à la place de RequestsPerMinute) : 1500 ≈ 100 Mo/s.
+56 -1
View File
@@ -363,6 +363,58 @@ dans le dossier (les images inutilisées se suppriment dans « Fichiers du serve
`games.edit`, audit `catalog.image_upload`. Le launcher des joueurs vérifie que l'image téléchargée a bien
l'empreinte de son nom : l'adresse figurant dans le catalogue signé, l'image est couverte par cette signature.
## 3 octies. Demandes des joueurs (bouton « Aide ») et purge automatique (VPS)
Le bouton **Aide** du launcher permet aux joueurs d'envoyer, **sans compte**, un avis, une demande d'évolution, un
bug ou autre chose, rattaché au jeu sélectionné. Les demandes se lisent dans le menu d'administration, page
**« Demandes des joueurs »** (permissions `feedback.read` et `feedback.manage`). C'est la **première route
d'écriture ouverte à tous** : elle est désactivée tant que tu ne l'actives pas.
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 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`) :
```json
"Feedback": {
"Enabled": true,
"Games": [ "EZR" ]
}
```
`Games` : identifiants (`gameId` de `launcher.json`) des jeux qui acceptent des demandes ; vide = les jeux de la
section `Games`. Les autres réglages (limites, plafonds, durées de conservation) sont décrits dans
`appsettings.json`. En cas d'afflux, relève `ProofDifficulty` (18 par défaut, 26 au plus).
2. **nginx** : ajoute dans `/etc/nginx/conf.d/securite.conf` (à côté de `api_par_ip`) :
```
limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m;
```
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
sans action d'un propriétaire) : page **« Groupes et permissions »** › groupe « Administrateur » › coche
« Demandes des joueurs (lecture) » et « (traitement) ». Le groupe « Éditeur » n'y a pas accès par défaut (le texte
libre peut contenir des données personnelles).
5. Test : launcher › bouton **Aide** (point d'interrogation, en haut à droite) › Bug › envoyer ; la référence
`FB-XXXX-XXXX` s'affiche. Menu d'administration › « Demandes des joueurs » › le jeu › la demande est là.
**Conservation (RGPD) — purge automatique**, au démarrage du service puis toutes les 24 heures :
| Données | Durée (réglage) | Journal |
|---|---|---|
| Journal d'audit `audit-AAAA-MM.jsonl` | 12 mois (`Audit:RetentionMonths`, 1 à 120) | `audit.purge` |
| Demande close (traitée, rejetée, doublon) | 12 mois après la clôture (`Feedback:RetentionClosedMonths`) | `feedback.purge` |
| Toute demande | 24 mois après réception (`Feedback:RetentionMaxMonths`) | `feedback.purge` |
Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la durée (le mois le plus ancien gardé peut
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`).
## 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 :
@@ -466,6 +518,7 @@ Le service n'a pas besoin d'être redémarré : une révocation ferme immédiate
|---|---|
| Journal du service (connexions, refus, erreurs) | `journalctl -u launcher-api -f` |
| Journal d'audit (une ligne JSON par action) | `sudo tail -n 20 /var/lib/launcher-api/audit/audit-$(date +%Y-%m).jsonl` |
| Demandes des joueurs reçues (nombre par jeu) | `sudo find /var/lib/launcher-api/feedback -name '*.json' \| cut -d/ -f6 \| sort \| uniq -c` |
| Niveau de durcissement du service | `systemd-analyze security launcher-api` |
| État | `systemctl status launcher-api` |
@@ -483,11 +536,13 @@ recréer les administrateurs.
/var/lib/launcher-api/ données privées (deploy, 700)
├─ admins.json (+ .bak) administrateurs, clés publiques, invitations (empreintes)
├─ server-id identifiant aléatoire du serveur
├─ audit/audit-AAAA-MM.jsonl journal d'audit
├─ audit/audit-AAAA-MM.jsonl journal d'audit (12 mois, purge automatique)
├─ catalog.json (+ .bak) catalogue publié (référence de la révision)
├─ catalog-history/ 20 dernières versions publiées
├─ 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)
└─ 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)
+67 -1
View File
@@ -7,7 +7,8 @@
#
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
# (les DEUX blocs ci-dessous ; le second, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies)
# (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)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
# =============================================================================
@@ -53,3 +54,68 @@ location ^~ /api/v1/catalog/images/ {
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Demandes des joueurs (bouton « Aide » du launcher) : seules routes d'ÉCRITURE ouvertes à tous (sans compte).
# • corps de 16 Ko au plus ; limite de débit propre, plus stricte (zone feedback_par_ip, à ajouter dans
# /etc/nginx/conf.d/securite.conf : « limit_req_zone $binary_remote_addr zone=feedback_par_ip:10m rate=6r/m; ») ;
# • access_log off : l'heure d'envoi d'une demande ne peut pas être rapprochée d'une adresse IP dans les journaux
# nginx (la demande est anonyme ; le serveur ne garde les adresses qu'en mémoire, pour ses limites).
# Correspondance EXACTE (« = ») : prioritaire sur « ^~ /api/ », et limitée aux deux routes des joueurs (les routes
# des administrateurs, /api/v1/feedback/overview et /api/v1/games/…/feedback, restent sous le bloc « /api/ »).
location = /api/v1/feedback/challenge {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 16k;
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 30s;
proxy_send_timeout 30s;
}
location = /api/v1/feedback {
limit_req zone=feedback_par_ip burst=6 nodelay;
limit_req_status 429;
access_log off;
client_max_body_size 16k;
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 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;
}
+34 -2
View File
@@ -6,6 +6,7 @@ using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
@@ -272,6 +273,36 @@ public sealed class AdminSession : IAsyncDisposable
public Task<ManifestSignatureState> SignOnlineManifestAsync(string gameId, ManifestSignRequest request, CancellationToken ct = default) =>
SendSignedAsync<ManifestSignatureState>(HttpMethod.Post, ApiRoutes.ManifestSignature(CheckGameId(gameId)), request, ct);
// ------------------------------------------------------------------ Demandes des joueurs
public Task<FeedbackOverview> GetFeedbackOverviewAsync(CancellationToken ct = default) =>
GetAsync<FeedbackOverview>(ApiRoutes.FeedbackOverview, ct);
/// <param name="status">Statut précis, « open » (non closes) ou « closed » ; null : toutes.</param>
public Task<FeedbackPage> GetFeedbackPageAsync(string gameId, string? kind = null, string? status = null, string? cursor = null,
int limit = 100, CancellationToken ct = default) =>
GetAsync<FeedbackPage>(ApiRoutes.GameFeedback(CheckGameId(gameId), kind, status, cursor, limit), ct);
public Task<FeedbackDetail> GetFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
GetAsync<FeedbackDetail>(ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), ct);
/// <summary>Statut et note interne (409 « revision_conflict » si la demande a changé depuis <paramref name="revision"/>).</summary>
public Task<FeedbackDetail> UpdateFeedbackAsync(string gameId, string id, long revision, string status, string? adminNote,
CancellationToken ct = default) =>
SendSignedAsync<FeedbackDetail>(HttpMethod.Put, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)),
new FeedbackUpdateRequest { Revision = revision, Status = status, AdminNote = adminNote }, ct);
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
/// <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));
private static string CheckPublicationId(string id) =>
PublishingLimits.IsValidPublicationId(id) ? id : throw new ArgumentException("Identifiant de publication invalide.", nameof(id));
@@ -293,7 +324,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.");
@@ -319,7 +351,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)
{
@@ -3,6 +3,7 @@ using System.Net.Http.Headers;
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
namespace GameLauncher.ApiClient;
@@ -106,6 +107,65 @@ public sealed class LauncherApiClient : IDisposable
}), null, ct).ConfigureAwait(false);
}
// ------------------------------------------------------------------ Demandes des joueurs (routes anonymes)
/// <summary>
/// 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) =>
(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) ||
nonce.Length is 0 or > 64)
throw new ApiException(0, "invalid_response", "Réponse du serveur illisible.");
var solution = await Task.Run(() => FeedbackProof.Solve(nonce, challenge.Difficulty, ct), ct).ConfigureAwait(false);
return await SendAsync<FeedbackSubmitted>(HttpMethod.Post, ApiRoutes.Feedback,
Serialize(submission with { ChallengeId = challenge.ChallengeId, Solution = solution }), null, ct).ConfigureAwait(false);
}
// ------------------------------------------------------------------ Transport
internal static byte[] Serialize<T>(T value) => JsonSerializer.SerializeToUtf8Bytes(value, ApiJson.Options);
@@ -131,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)
@@ -164,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)
{
@@ -176,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)
@@ -189,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();
@@ -199,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);
}
@@ -261,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);
+36
View File
@@ -108,6 +108,42 @@ public static class ApiRoutes
public static string ManifestSignature(string gameId) => $"v1/games/{gameId}/signature";
// ------------------------------------------------------------------ Demandes des joueurs (aide, avis, bugs…)
/// <summary>Défi anti-spam (anonyme) à résoudre avant l'envoi d'une demande.</summary>
public const string FeedbackChallenge = "v1/feedback/challenge";
/// <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";
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
/// <summary>Demandes d'un jeu (identifiant : caractères sûrs ; filtres et curseur encodés).</summary>
public static string GameFeedback(string gameId, string? kind = null, string? status = null, string? cursor = null, int limit = 100)
{
var route = $"v1/games/{gameId}/feedback?limit={limit}";
if (!string.IsNullOrEmpty(kind)) route += $"&kind={Uri.EscapeDataString(kind)}";
if (!string.IsNullOrEmpty(status)) route += $"&status={Uri.EscapeDataString(status)}";
if (!string.IsNullOrEmpty(cursor)) route += $"&cursor={Uri.EscapeDataString(cursor)}";
return route;
}
/// <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}";
// ------------------------------------------------------------------ Publication d'une version
public const string PublicationStartPattern = "v1/games/{gameId}/publications";
public const string PublicationPattern = "v1/publications/{id}";
@@ -13,6 +13,14 @@ public sealed record AuditEntry
public Guid? ActorId { get; init; }
public string? ActorName { get; init; }
public string? KeyId { get; init; }
/// <summary>
/// Nom de l'appareil de la clé <see cref="KeyId"/>, tel que défini à l'enregistrement de la clé (invitation).
/// Jamais écrit dans les fichiers du journal : ajouté par le serveur à la lecture (null : clé inconnue, ou serveur
/// plus ancien).
/// </summary>
public string? KeyLabel { get; init; }
public string? Ip { get; init; }
public string? Target { get; init; }
public string? Details { get; init; }
@@ -68,4 +68,10 @@ public static class ApiErrorCodes
/// <summary>Espace disque insuffisant sur le serveur.</summary>
public const string InsufficientStorage = "insufficient_storage";
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
public const string FeedbackFull = "feedback_full";
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
}
@@ -0,0 +1,220 @@
namespace GameLauncher.Contracts.Feedback;
// ------------------------------------------------------------------ Joueur (routes anonymes)
/// <summary>Défi anti-spam (POST v1/feedback/challenge) : à résoudre avant l'envoi, voir <see cref="FeedbackProof"/>.</summary>
public sealed record FeedbackChallenge
{
/// <summary>Identifiant scellé par le serveur (sans état), à renvoyer avec la demande.</summary>
public string ChallengeId { get; init; } = "";
/// <summary>Valeur aléatoire (base64url) à combiner avec la solution.</summary>
public string Nonce { get; init; } = "";
/// <summary>Nombre de bits à zéro exigés en tête de l'empreinte.</summary>
public int Difficulty { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
}
/// <summary>Informations techniques jointes à une demande (facultatives, choisies par le joueur).</summary>
public sealed record FeedbackTechInfo
{
public string? LauncherVersion { get; init; }
/// <summary>Version du jeu installée (absente si le jeu n'est pas installé).</summary>
public string? GameVersion { get; init; }
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
public string? Os { get; init; }
/// <summary>Langue de Windows (« fr-FR »).</summary>
public string? Language { get; init; }
}
/// <summary>
/// Demande envoyée par un joueur (POST v1/feedback), SANS compte : aucun identifiant du joueur ni de son PC.
/// Le serveur ne fait confiance à rien : il revalide tout avec <see cref="FeedbackRules"/> et pose lui-même la date,
/// l'identifiant, la référence et le statut.
/// </summary>
public sealed record FeedbackSubmission
{
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
public string Title { get; init; } = "";
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
public Dictionary<string, string> Fields { get; init; } = new();
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; } = "";
public string Solution { get; init; } = "";
}
/// <summary>Demande enregistrée : référence à citer (Discord, e-mail…).</summary>
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
/// <summary>Statuts d'une demande (traitement par les administrateurs).</summary>
public static class FeedbackStatuses
{
public const string New = "new";
public const string InProgress = "in_progress";
public const string Done = "done";
public const string Rejected = "rejected";
public const string Duplicate = "duplicate";
public static readonly IReadOnlyList<string> All = new[] { New, InProgress, Done, Rejected, Duplicate };
public static bool IsValid(string? status) => status is not null && All.Contains(status, StringComparer.Ordinal);
/// <summary>Demande close (traitée, rejetée, doublon) : comptée pour la durée de conservation après clôture.</summary>
public static bool IsClosed(string? status) => status is Done or Rejected or Duplicate;
public static string Label(string? status) => status switch
{
New => "Nouvelle",
InProgress => "En cours",
Done => "Traitée",
Rejected => "Rejetée",
Duplicate => "Doublon",
_ => status ?? "?"
};
}
/// <summary>Résumé d'une demande (listes).</summary>
public sealed record FeedbackSummary
{
public string Id { get; init; } = "";
public string Reference { get; init; } = "";
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public string Title { get; init; } = "";
public string Status { get; init; } = FeedbackStatuses.New;
public DateTimeOffset ReceivedAt { get; init; }
public DateTimeOffset? StatusChangedAt { get; init; }
/// <summary>Note (avis) ou gravité (bug), pour l'aperçu de la liste.</summary>
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; }
}
/// <summary>Page de demandes d'un jeu (GET v1/games/{gameId}/feedback), les plus récentes d'abord.</summary>
public sealed record FeedbackPage
{
public IReadOnlyList<FeedbackSummary> Items { get; init; } = Array.Empty<FeedbackSummary>();
/// <summary>Suite de la liste (null : fin).</summary>
public string? NextCursor { get; init; }
/// <summary>Nombre de demandes correspondant aux filtres.</summary>
public int Total { get; init; }
}
/// <summary>Demande complète (GET v1/games/{gameId}/feedback/{id}).</summary>
public sealed record FeedbackDetail
{
public FeedbackSummary Summary { get; init; } = new();
public int FormVersion { 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; }
/// <summary>Nom de l'administrateur qui a changé le statut ou la note en dernier.</summary>
public string? StatusChangedBy { get; init; }
}
/// <summary>Changement de statut et/ou de note interne (PUT, requête signée).</summary>
public sealed record FeedbackUpdateRequest
{
/// <summary>Révision lue : 409 « revision_conflict » si la demande a changé entre-temps.</summary>
public long Revision { get; init; }
public string Status { get; init; } = FeedbackStatuses.New;
public string? AdminNote { get; init; }
}
/// <summary>Compteurs d'un jeu (tableau de bord, tuiles du choix du jeu).</summary>
public sealed record FeedbackGameCounts
{
public string GameId { get; init; } = "";
public int New { get; init; }
public int Open { get; init; }
public int Total { get; init; }
}
/// <summary>Vue d'ensemble (GET v1/feedback/overview).</summary>
public sealed record FeedbackOverview
{
/// <summary>Réception des demandes activée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
public bool Enabled { get; init; }
/// <summary>Plafond atteint (nombre, taille ou espace disque) : les nouvelles demandes sont refusées.</summary>
public bool StorageFull { get; init; }
public IReadOnlyList<FeedbackGameCounts> Games { get; init; } = Array.Empty<FeedbackGameCounts>();
/// <summary>Durées de conservation appliquées (mois).</summary>
public int RetentionClosedMonths { get; init; }
public int RetentionMaxMonths { get; init; }
}
@@ -0,0 +1,158 @@
namespace GameLauncher.Contracts.Feedback;
/// <summary>Type d'un champ de formulaire.</summary>
public enum FeedbackFieldKind
{
/// <summary>Texte libre 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>
Choice,
/// <summary>Note de 1 à 5 (valeur : « 1 » à « 5 »).</summary>
Rating
}
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
public sealed record FeedbackOption(string Id, string Label);
/// <summary>Champ prédéfini d'un formulaire.</summary>
/// <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>
public sealed record FeedbackField(
string Id,
string Label,
FeedbackFieldKind Kind,
bool Required,
string? Hint = null,
int MaxLength = FeedbackRules.MaxMultilineLength,
IReadOnlyList<FeedbackOption>? Options = null)
{
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>
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",
_ => value!
};
}
/// <summary>Formulaire d'un type de demande.</summary>
/// <param name="Kind">Identifiant stable (<see cref="FeedbackKinds"/>).</param>
/// <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>
public sealed record FeedbackForm(
string Kind,
string Label,
string Description,
string IconKey,
string TitleHint,
bool TechByDefault,
IReadOnlyList<FeedbackField> Fields)
{
public FeedbackField? Field(string id) => Fields.FirstOrDefault(f => f.Id == id);
}
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
public static class FeedbackKinds
{
public const string Review = "review";
public const string Feature = "feature";
public const string Bug = "bug";
public const string Other = "other";
}
/// <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.
/// </summary>
public static class FeedbackForms
{
/// <summary>Version des formulaires (enregistrée avec chaque demande).</summary>
public const int CurrentVersion = 1;
private static FeedbackOption O(string id, string label) => new(id, label);
public static readonly IReadOnlyList<FeedbackForm> All = new[]
{
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("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
O("sound", "Son et musique"), O("performance", "Performances"), O("story", "Histoire et ambiance"),
O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("liked", "Ce que vous aimez", FeedbackFieldKind.Multiline, Required: false,
"Ce qui vous plaît le plus.", MaxLength: 1500),
new FeedbackField("improve", "Ce qui pourrait être amélioré", FeedbackFieldKind.Multiline, Required: false,
"Ce qui vous gêne ou vous manque.", MaxLength: 1500),
new FeedbackField("recommend", "Recommanderiez-vous le jeu ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
{
O("yes", "Oui"), O("maybe", "Peut-être"), O("no", "Non")
})
}),
new FeedbackForm(FeedbackKinds.Feature, "Demande d'évolution", "Proposez une idée ou une amélioration.", "Lightbulb",
"Votre idée en une phrase", TechByDefault: false, new[]
{
new FeedbackField("area", "Domaine", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("gameplay", "Gameplay"), O("content", "Contenu (niveaux, objets, ennemis…)"), O("interface", "Interface et menus"),
O("accessibility", "Accessibilité"), O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("idea", "Description de l'idée", FeedbackFieldKind.Multiline, Required: true,
"Décrivez ce que vous aimeriez voir dans le jeu."),
new FeedbackField("problem", "Quel problème cela résoudrait-il ?", FeedbackFieldKind.Multiline, Required: false,
"Facultatif : pourquoi cette idée vous semble utile.", MaxLength: 1500),
new FeedbackField("importance", "Importance pour vous", FeedbackFieldKind.Choice, Required: false, Options: new[]
{
O("bonus", "Un petit plus"), O("useful", "Utile"), O("important", "Importante")
})
}),
new FeedbackForm(FeedbackKinds.Bug, "Bug", "Signalez un problème rencontré dans le jeu ou le launcher.", "Bug",
"Le problème en une phrase", TechByDefault: true, new[]
{
new FeedbackField("category", "Catégorie", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("crash", "Plantage ou fermeture du jeu"), O("graphics", "Graphismes"), O("sound", "Son"), O("gameplay", "Gameplay"),
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[]
{
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
}),
new FeedbackField("frequency", "Fréquence", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("always", "À chaque fois"), O("often", "Souvent"), O("sometimes", "Parfois"), O("once", "Une seule fois")
}),
new FeedbackField("steps", "Étapes pour reproduire", FeedbackFieldKind.Multiline, Required: true,
"1. Je lance une partie…\n2. J'ouvre l'inventaire…"),
new FeedbackField("expected", "Résultat attendu", FeedbackFieldKind.Multiline, Required: false,
"Ce qui aurait dû se passer.", MaxLength: 1000),
new FeedbackField("actual", "Résultat obtenu", FeedbackFieldKind.Multiline, Required: true,
"Ce qui s'est passé à la place (message d'erreur…).", MaxLength: 1500)
}),
new FeedbackForm(FeedbackKinds.Other, "Autre", "Une question, une remarque, autre chose.", "Forum",
"Le sujet de votre message", TechByDefault: false, new[]
{
new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true, "Votre message.")
})
};
public static FeedbackForm? Find(string? kind) => All.FirstOrDefault(f => f.Kind == kind);
public static string KindLabel(string? kind) => Find(kind)?.Label ?? kind ?? "?";
}
@@ -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;
}
}
@@ -0,0 +1,65 @@
using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Preuve de travail anti-spam des demandes anonymes : trouver une solution (nombre) telle que
/// SHA-256(domaine ‖ nonce ‖ solution) commence par <c>difficulty</c> bits à zéro. Environ 2^difficulty essais
/// (une fraction de seconde pour un joueur, coûteux pour un robot qui veut en envoyer des milliers) ; la
/// vérification par le serveur ne coûte qu'une empreinte. Aucun service tiers, aucun captcha.
/// </summary>
public static class FeedbackProof
{
public const int MinDifficulty = 8;
public const int MaxDifficulty = 26;
/// <summary>Solution : nombre décimal (20 chiffres au plus).</summary>
public const int MaxSolutionLength = 20;
private static readonly byte[] Domain = Encoding.ASCII.GetBytes("GameLauncher/Feedback/Proof/v1\n");
/// <summary>Bits à zéro en tête de l'empreinte de cette solution.</summary>
public static int LeadingZeroBits(ReadOnlySpan<byte> nonce, ulong solution)
{
Span<byte> data = stackalloc byte[Domain.Length + nonce.Length + 8];
Domain.CopyTo(data);
nonce.CopyTo(data[Domain.Length..]);
BinaryPrimitives.WriteUInt64BigEndian(data[(Domain.Length + nonce.Length)..], solution);
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(data, hash);
var bits = 0;
foreach (var b in hash)
{
if (b == 0)
{
bits += 8;
continue;
}
bits += System.Numerics.BitOperations.LeadingZeroCount((uint)b) - 24;
break;
}
return bits;
}
public static bool Verify(ReadOnlySpan<byte> nonce, int difficulty, string? solution) =>
solution is { Length: > 0 and <= MaxSolutionLength } &&
solution.All(char.IsAsciiDigit) &&
ulong.TryParse(solution, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var value) &&
LeadingZeroBits(nonce, value) >= difficulty;
/// <summary>Cherche une solution (launcher). Annulable ; part d'un point aléatoire.</summary>
public static string Solve(ReadOnlySpan<byte> nonce, int difficulty, CancellationToken ct = default)
{
if (difficulty is < 0 or > MaxDifficulty) throw new ArgumentOutOfRangeException(nameof(difficulty));
var start = BinaryPrimitives.ReadUInt64BigEndian(RandomNumberGenerator.GetBytes(8)) >> 1;
for (var i = 0UL; ; i++)
{
if ((i & 0xFFFF) == 0) ct.ThrowIfCancellationRequested();
var candidate = start + i;
if (LeadingZeroBits(nonce, candidate) >= difficulty)
return candidate.ToString(System.Globalization.CultureInfo.InvariantCulture);
}
}
}
@@ -0,0 +1,266 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Règles communes au launcher (contrôle avant l'envoi) et au serveur (refus de toute valeur invalide) pour les
/// demandes des joueurs. La route d'envoi est anonyme : n'importe qui peut l'appeler avec un client modifié, d'où
/// des bornes strictes et le refus des caractères invisibles (de contrôle, de mise en forme, d'inversion du sens
/// d'écriture) qui pourraient tromper l'administrateur qui lit la demande.
/// </summary>
public static partial class FeedbackRules
{
public const int MinTitleLength = 5;
public const int MaxTitleLength = 120;
public const int MaxMultilineLength = 2000;
public const int MaxLines = 40;
/// <summary>Total des textes d'une demande (titre et champs).</summary>
public const int MaxTotalLength = 8000;
/// <summary>Corps d'une requête d'envoi (Kestrel et nginx).</summary>
public const int MaxRequestBytes = 16 * 1024;
public const int MaxAdminNoteLength = 500;
/// <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();
/// <summary>Référence montrée au joueur : « FB-7K3Q-9P2M » (base32 sans caractères ambigus).</summary>
[GeneratedRegex(@"\AFB-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}\z")]
private static partial Regex ReferenceRegex();
[GeneratedRegex(@"\A[0-9A-Za-z][0-9A-Za-z.+\-]{0,31}\z")]
private static partial Regex VersionRegex();
[GeneratedRegex(@"\A[A-Za-z0-9][A-Za-z0-9 .,()+\-]{0,79}\z")]
private static partial Regex OsRegex();
[GeneratedRegex(@"\A[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}\z")]
private static partial Regex LanguageRegex();
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
public static bool IsValidReference(string? reference) => reference is not null && ReferenceRegex().IsMatch(reference);
/// <summary>Alphabet des références (Crockford : ni I, L, O, U).</summary>
public const string ReferenceAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/// <summary>Référence aléatoire (40 bits).</summary>
public static string NewReference()
{
var bytes = System.Security.Cryptography.RandomNumberGenerator.GetBytes(5);
ulong value = 0;
foreach (var b in bytes) value = (value << 8) | b;
var chars = new char[8];
for (var i = 7; i >= 0; i--)
{
chars[i] = ReferenceAlphabet[(int)(value & 31)];
value >>= 5;
}
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <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).
/// </summary>
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors)
{
if (submission is null)
{
errors.Add("Demande vide.");
return null;
}
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)
{
errors.Add("Type de demande inconnu.");
return null;
}
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
if (title is null || title.Length < MinTitleLength)
errors.Add($"Titre : {MinTitleLength} à {MaxTitleLength} caractères sur une ligne, sans caractère spécial invisible.");
var fields = new Dictionary<string, string>(StringComparer.Ordinal);
var given = submission.Fields ?? new Dictionary<string, string>();
foreach (var key in given.Keys)
if (form.Field(key) is null)
{
errors.Add("Champ inconnu dans la demande.");
break;
}
var total = title?.Length ?? 0;
foreach (var field in form.Fields)
{
given.TryGetValue(field.Id, out var raw);
var value = CheckField(field, raw, errors);
if (value is null) continue;
fields[field.Id] = value;
total += value.Length;
}
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
{
Title = title!,
Fields = fields,
Tech = tech
};
}
/// <summary>Valeur d'un champ contrôlée (null : vide ou invalide ; l'erreur est ajoutée si besoin).</summary>
public static string? CheckField(FeedbackField field, string? raw, ICollection<string> errors)
{
switch (field.Kind)
{
case FeedbackFieldKind.Choice:
case FeedbackFieldKind.Rating:
if (string.IsNullOrEmpty(raw))
{
if (field.Required) errors.Add($"{field.Label} : choix obligatoire.");
return null;
}
var valid = field.Kind == FeedbackFieldKind.Rating
? raw is "1" or "2" or "3" or "4" or "5"
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
if (!valid)
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
return raw;
default:
var text = CleanMultiline(raw, field.MaxLength, out var problem);
if (problem is not null)
{
errors.Add($"{field.Label} : {problem}");
return null;
}
if (text is null && field.Required) errors.Add($"{field.Label} : champ obligatoire.");
return text;
}
}
/// <summary>
/// Texte sur plusieurs lignes : fins de ligne « \n », tabulations remplacées par une espace, espaces de fin de
/// ligne retirés, trois lignes vides consécutives au plus. Null si vide. <paramref name="problem"/> : raison du
/// refus (caractère interdit, trop long, trop de lignes).
/// </summary>
public static string? CleanMultiline(string? value, int maxLength, out string? problem)
{
problem = null;
if (string.IsNullOrWhiteSpace(value)) return null;
if (value.Length > maxLength * 2)
{
problem = $"{maxLength} caractères au plus.";
return null;
}
var sb = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
var c = value[i];
if (c == '\r')
{
sb.Append('\n');
if (i + 1 < value.Length && value[i + 1] == '\n') i++;
continue;
}
if (c == '\n')
{
sb.Append('\n');
continue;
}
if (c == '\t')
{
sb.Append(' ');
continue;
}
if (char.IsHighSurrogate(c))
{
if (i + 1 < value.Length && char.IsLowSurrogate(value[i + 1]) &&
CharUnicodeInfo.GetUnicodeCategory(value, i) is not (UnicodeCategory.Format or UnicodeCategory.PrivateUse))
{
sb.Append(c).Append(value[i + 1]);
i++;
continue;
}
problem = "caractère non autorisé.";
return null;
}
if (char.IsLowSurrogate(c) || char.IsControl(c) || c is '￾' or '￿' ||
CharUnicodeInfo.GetUnicodeCategory(c) is UnicodeCategory.Format or UnicodeCategory.LineSeparator
or UnicodeCategory.ParagraphSeparator or UnicodeCategory.PrivateUse)
{
problem = "caractère invisible ou spécial non autorisé.";
return null;
}
sb.Append(c);
}
var lines = sb.ToString().Split('\n').Select(l => l.TrimEnd()).ToList();
var kept = new List<string>(lines.Count);
var blanks = 0;
foreach (var line in lines)
{
blanks = line.Length == 0 ? blanks + 1 : 0;
if (blanks <= 2) kept.Add(line);
}
var text = string.Join('\n', kept).Trim('\n', ' ');
if (text.Length == 0) return null;
if (text.Length > maxLength)
{
problem = $"{maxLength} caractères au plus ({text.Length} saisis).";
return null;
}
if (text.Count(ch => ch == '\n') + 1 > MaxLines)
{
problem = $"{MaxLines} lignes au plus.";
return null;
}
return text;
}
/// <summary>Informations techniques : formats stricts (rien d'autre que ce que le launcher envoie).</summary>
public static FeedbackTechInfo? CheckTech(FeedbackTechInfo? tech, ICollection<string> errors)
{
if (tech is null) return null;
bool Ok(string? value, Regex regex) => value is null || regex.IsMatch(value);
if (!Ok(tech.LauncherVersion, VersionRegex()) || !Ok(tech.GameVersion, VersionRegex()) ||
!Ok(tech.Os, OsRegex()) || !Ok(tech.Language, LanguageRegex()))
{
errors.Add("Informations techniques invalides.");
return null;
}
return tech.LauncherVersion is null && tech.GameVersion is null && tech.Os is null && tech.Language is null ? null : tech;
}
/// <summary>Note interne d'un administrateur (vide : null) ; <paramref name="valid"/> faux si elle est refusée.</summary>
public static string? CleanAdminNote(string? note, out bool valid)
{
var text = CleanMultiline(note, MaxAdminNoteLength, out var problem);
valid = problem is null;
return valid ? text : null;
}
/// <summary>Version au format des informations techniques (« 2.5.0 », « 0.9.3-beta »), sinon null.</summary>
public static string? SafeVersion(string? value) => value is not null && VersionRegex().IsMatch(value) ? value : null;
/// <summary>Description de Windows au format des informations techniques, sinon null.</summary>
public static string? SafeOs(string? value) => value is not null && OsRegex().IsMatch(value) ? value : null;
public static string? SafeLanguage(string? value) => value is not null && LanguageRegex().IsMatch(value) ? value : null;
}
@@ -84,7 +84,7 @@ public static partial class AdminGroups
public static string? DefaultDescription(string groupId) => groupId switch
{
AdminRoles.Owner => "Tous les droits, y compris ceux des permissions créées plus tard. Groupe système : non modifiable.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur et journal d'audit.",
AdminRoles.Admin => "Jeux, notes de version, publication des versions, fichiers du serveur, demandes des joueurs et journal d'audit.",
AdminRoles.Editor => "Informations des jeux et notes de version.",
_ => null
};
@@ -28,9 +28,15 @@ public static class AdminPermissions
/// <summary>Fichiers du site public (www) : consultation, suppression des fichiers inutilisés, nettoyage d'« objects ».</summary>
public const string FilesManage = "files.manage";
/// <summary>Lecture des demandes des joueurs (avis, évolutions, bugs…), rangées par jeu.</summary>
public const string FeedbackRead = "feedback.read";
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
public const string FeedbackManage = "feedback.manage";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -45,6 +51,8 @@ public static class AdminPermissions
ChangelogEdit => "Notes de version",
ReleasesPublish => "Publication des versions",
FilesManage => "Fichiers du serveur",
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
_ => null
};
@@ -58,6 +66,10 @@ public static class AdminPermissions
ChangelogEdit => "Corriger les notes des versions déjà publiées.",
ReleasesPublish => "Publier une nouvelle version d'un jeu et signer une version déjà en ligne.",
FilesManage => "Parcourir le site, supprimer les fichiers inutilisés et nettoyer le dossier « objects ».",
FeedbackRead => "Lire les demandes envoyées par les joueurs (avis, évolutions, bugs) : leur texte libre peut contenir " +
"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).",
_ => null
};
}
@@ -26,7 +26,8 @@ public static class AdminRoles
[Admin] = new[]
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -56,7 +56,9 @@ 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("audit.", "Journal d'audit (consultations, exports, purges)"),
new("security", "Refus de sécurité")
};
Results = new List<Choice>
@@ -428,7 +430,10 @@ public sealed class AuditItem
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
// Appareil : nom choisi à l'enregistrement de la clé (invitation), fourni par le serveur — jamais le nom Windows.
DeviceText = string.IsNullOrEmpty(entry.KeyLabel) ? "" : $"Appareil « {entry.KeyLabel} »";
var origin = new List<string>();
if (DeviceText.Length > 0) origin.Add(DeviceText);
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
@@ -447,6 +452,10 @@ public sealed class AuditItem
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
/// <summary>« Appareil « PC bureau » » (vide : entrée sans clé, ou clé inconnue du serveur).</summary>
public string DeviceText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
@@ -486,12 +495,21 @@ 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)",
"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
};
@@ -511,7 +529,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)
};
@@ -524,14 +542,14 @@ public static class AuditTexts
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
item.ActionText, e.Action, item.ResultText, e.ActorName, e.KeyLabel, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
@@ -0,0 +1,25 @@
using GameLauncher.Contracts.Security;
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.
/// </summary>
public sealed class FeedbackAdminModule : ILauncherModule
{
public string Id => "feedback";
public string DisplayName => "Demandes des joueurs";
public void Register(IModuleRegistry registry) =>
registry.AddAdminPanel(new AdminPanelDescriptor(
FeedbackPanelViewModel.PanelId,
"Demandes des joueurs",
"Avis, demandes d'évolution, bugs et autres demandes envoyés depuis le bouton « Aide » du launcher, rangés par jeu.",
"MessageQuestion",
AdminPermissions.FeedbackRead,
Order: 15,
context => new FeedbackPanelViewModel(context)));
}
@@ -0,0 +1,315 @@
using System.Globalization;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>Tuile du choix du jeu, avec les compteurs de demandes (même modèle d'affichage que <see cref="ViewModels.GameTile"/>).</summary>
public sealed class FeedbackGameTile : ObservableObject
{
private string _detail;
private int _newCount;
public FeedbackGameTile(Choice choice, string name, Action<Choice> select)
{
Choice = choice;
Name = name;
_detail = $"Identifiant {choice.Value}";
SelectCommand = new RelayCommand(() => select(choice));
}
public Choice Choice { get; }
public string Name { get; }
/// <summary>« 3 nouvelles · 5 à traiter · 12 au total ».</summary>
public string Detail
{
get => _detail;
private set => SetProperty(ref _detail, value);
}
public int NewCount
{
get => _newCount;
private set => SetProperty(ref _newCount, value);
}
public RelayCommand SelectCommand { get; }
public void Apply(FeedbackGameCounts? counts)
{
NewCount = counts?.New ?? 0;
Detail = counts is null
? $"Identifiant {Choice.Value} · ne reçoit pas de demandes"
: counts.Total == 0
? "Aucune demande"
: $"{Plural(counts.New, "nouvelle", "nouvelles")} · {counts.Open} à traiter · {counts.Total} au total";
}
internal static string Plural(int count, string one, string many) => $"{count} {(count > 1 ? many : one)}";
}
/// <summary>Demande dans la liste (résumé).</summary>
public sealed class FeedbackItem : ObservableObject
{
private FeedbackSummary _summary;
public FeedbackItem(FeedbackSummary summary) => _summary = summary;
public FeedbackSummary Summary
{
get => _summary;
set
{
_summary = value;
OnPropertyChanged(string.Empty); // toutes les propriétés
}
}
public string Id => _summary.Id;
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";
public string Status => _summary.Status;
public string StatusLabel => FeedbackStatuses.Label(_summary.Status);
public bool IsNew => _summary.Status == FeedbackStatuses.New;
public bool IsClosed => FeedbackStatuses.IsClosed(_summary.Status);
public string? Badge => _summary.Badge;
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);
/// <summary>« Bug · FB-7K3Q-9P2M · 08/11/2026 21:14 ».</summary>
public string DetailText => $"{KindLabel} · {Reference} · {DateText}";
public bool Matches(string search) =>
string.IsNullOrWhiteSpace(search) ||
Title.Contains(search.Trim(), StringComparison.CurrentCultureIgnoreCase) ||
Reference.Contains(search.Trim(), StringComparison.OrdinalIgnoreCase);
}
/// <summary>Champ d'une demande, tel qu'affiché dans la fiche (libellé du formulaire, valeur en texte brut).</summary>
/// <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);
/// <summary>
/// Fiche d'une demande : tous les champs (texte brut, jamais interprété : il vient d'un inconnu), informations
/// techniques, statut et note interne modifiables (feedback.manage), suppression.
/// </summary>
public sealed class FeedbackDetailView : ObservableObject
{
private readonly Action _changed;
private FeedbackDetail _detail;
private FeedbackStatusChoice _status;
private string _note;
public FeedbackDetailView(FeedbackDetail detail, bool canManage, Action changed, Action<string>? copy = null)
{
_detail = detail;
_changed = changed;
CanManage = canManage;
CopyReferenceCommand = new RelayCommand(() => copy?.Invoke(detail.Summary.Reference));
_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();
/// <summary>Statuts proposés (propriété d'instance : liaison WPF).</summary>
public IReadOnlyList<FeedbackStatusChoice> Statuses => AllStatuses;
/// <summary>Copie la référence dans le presse-papiers.</summary>
public RelayCommand CopyReferenceCommand { get; }
public FeedbackDetail Detail => _detail;
public string Id => _detail.Summary.Id;
public long Revision => _detail.Summary.Revision;
public bool CanManage { get; }
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";
public string ReceivedText =>
$"Reçue le {_detail.Summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}";
public string? StatusChangedText => _detail.Summary.StatusChangedAt is { } at
? $"Dernière modification le {at.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}" +
(string.IsNullOrEmpty(_detail.StatusChangedBy) ? "" : $" par {_detail.StatusChangedBy}")
: null;
public IReadOnlyList<FeedbackFieldLine> Lines { get; }
public bool HasTech => _detail.Tech is not null;
public string TechText => _detail.Tech is not { } tech ? "Non jointes par le joueur." : string.Join("\n", new[]
{
tech.LauncherVersion is { } l ? $"Launcher : {l}" : null,
$"Jeu : {tech.GameVersion ?? "non installé"}",
tech.Os is { } os ? $"Système : {os}" : null,
tech.Language is { } lang ? $"Langue : {lang}" : null
}.Where(l => l is not null));
public FeedbackStatusChoice Status
{
get => _status;
set
{
if (value is null || !SetProperty(ref _status, value)) return;
OnPropertyChanged(nameof(HasChanges));
_changed();
}
}
public string Note
{
get => _note;
set
{
if (!SetProperty(ref _note, value ?? "")) return;
OnPropertyChanged(nameof(HasChanges));
OnPropertyChanged(nameof(NoteLengthText));
_changed();
}
}
public int NoteMaxLength => FeedbackRules.MaxAdminNoteLength;
public string NoteLengthText => $"{_note.Trim().Length} / {FeedbackRules.MaxAdminNoteLength}";
/// <summary>Statut ou note différents de ceux enregistrés (note comparée telle que le serveur la nettoiera).</summary>
public bool HasChanges =>
_status.Value != _detail.Summary.Status ||
(FeedbackRules.CleanMultiline(_note, int.MaxValue / 4, out _) ?? "") != (_detail.AdminNote ?? "");
/// <summary>Note telle qu'envoyée (vide : null).</summary>
public string? NoteToSend => string.IsNullOrWhiteSpace(_note) ? null : _note;
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
{
var form = FeedbackForms.Find(detail.Summary.Kind);
var lines = new List<FeedbackFieldLine>();
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
field.Kind == FeedbackFieldKind.Multiline && FeedbackLinks.Contains(value)));
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
return lines;
}
}
@@ -0,0 +1,438 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Page « Demandes des joueurs » : avis, demandes d'évolution, bugs et autres demandes envoyés SANS compte depuis le
/// bouton « Aide » du launcher, rangés par jeu.
/// • plusieurs jeux : choix du jeu d'abord (tuiles avec le nombre de nouvelles demandes), rien n'est chargé avant ;
/// • liste filtrable (type, statut, recherche dans le titre ou la référence), pages de 100, les plus récentes d'abord ;
/// • fiche : champs en texte brut, informations techniques, statut et note interne (feedback.manage, requête signée,
/// révision attendue : si un autre administrateur l'a modifiée entre-temps, rien n'est écrasé), suppression ;
/// • chaque consultation est inscrite au journal d'audit par le serveur (le texte libre peut contenir des données
/// personnelles). Pas de réponse au joueur : les demandes sont anonymes (module futur « comptes »).
/// </summary>
public sealed class FeedbackPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "feedback.requests";
private const int PageSize = 100;
private readonly List<FeedbackItem> _loaded = new();
private readonly Dictionary<string, FeedbackGameTile> _tiles;
private FeedbackOverview? _overview;
private Choice? _selectedGame;
private Choice _kind;
private Choice _status;
private string _searchText = "";
private string? _nextCursor;
private int _total;
private FeedbackItem? _selectedItem;
private FeedbackDetailView? _detail;
private string? _statusMessage;
private bool _hasConflict;
private int _loadVersion;
public FeedbackPanelViewModel(IAdminContext context) : base(context)
{
GameChoices = context.Games.Select(g => new Choice(g.GameId, $"{g.GameName} ({g.GameId})")).ToList();
_selectedGame = GameChoices.Count == 1 ? GameChoices[0] : null;
_tiles = GameChoices.ToDictionary(c => c.Value!, c => new FeedbackGameTile(c,
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();
Statuses = new[]
{
new Choice("open", "À traiter (nouvelles et en cours)"),
new Choice(FeedbackStatuses.New, "Nouvelles"),
new Choice(FeedbackStatuses.InProgress, "En cours"),
new Choice("closed", "Closes (traitées, rejetées, doublons)"),
new Choice(FeedbackStatuses.Done, "Traitées"),
new Choice(FeedbackStatuses.Rejected, "Rejetées"),
new Choice(FeedbackStatuses.Duplicate, "Doublons"),
new Choice(null, "Toutes")
};
_kind = Kinds[0];
_status = Statuses[0];
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
LoadMoreCommand = new AsyncRelayCommand(LoadMoreAsync, () => !IsBusy && _nextCursor is not null);
SaveCommand = new AsyncRelayCommand(SaveAsync, () => !IsBusy && _detail is { CanManage: true, HasChanges: true } && !_hasConflict);
DeleteCommand = new AsyncRelayCommand(DeleteAsync, () => !IsBusy && _detail is { CanManage: true });
}
public bool CanManage => Context.Session.Has(AdminPermissions.FeedbackManage);
// ------------------------------------------------------------------ Jeu (modèles AdminGamePickerTemplate / AdminGameSwitcherTemplate)
public IReadOnlyList<Choice> GameChoices { get; }
public IReadOnlyList<FeedbackGameTile> GameTiles { get; }
public bool HasSeveralGames => GameChoices.Count > 1;
public bool ShowGamePicker => _selectedGame is null;
public bool ShowGameSwitcher => HasSeveralGames && _selectedGame is not null;
public bool HasSelectedGame => _selectedGame is not null;
public bool IsGameSwitchEnabled => !IsBusy;
public string GamePickerText => GameChoices.Count == 0
? "Aucun jeu n'est déclaré dans launcher.json."
: "Choisissez le jeu dont vous voulez lire les demandes : les demandes d'un jeu ne se mélangent jamais à celles d'un autre.";
public Choice? SelectedGame
{
get => _selectedGame;
set
{
if (value is null || Equals(value, _selectedGame)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged(); // la liste de l'écran revient au jeu affiché
return;
}
_selectedGame = value;
OnPropertyChanged();
foreach (var name in new[] { nameof(ShowGamePicker), nameof(ShowGameSwitcher), nameof(HasSelectedGame) }) OnPropertyChanged(name);
ClearList();
ErrorMessage = null;
StatusMessage = null;
GameLoad = LoadAsync();
}
}
/// <summary>Chargement lancé par le dernier choix de jeu (tests).</summary>
internal Task GameLoad { get; private set; } = Task.CompletedTask;
// ------------------------------------------------------------------ Serveur
/// <summary>Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).</summary>
public bool ShowDisabled => _overview is { Enabled: false };
/// <summary>Plafond atteint : les nouvelles demandes sont refusées.</summary>
public bool ShowStorageFull => _overview is { StorageFull: true };
public string RetentionText => _overview is null ? "" :
$"Conservation : {_overview.RetentionClosedMonths} mois après la clôture d'une demande, {_overview.RetentionMaxMonths} mois au plus (purge automatique).";
// ------------------------------------------------------------------ Filtres et liste
public IReadOnlyList<Choice> Kinds { get; }
public IReadOnlyList<Choice> Statuses { get; }
public Choice Kind
{
get => _kind;
set
{
if (value is null || Equals(value, _kind)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_kind = value;
OnPropertyChanged();
GameLoad = LoadAsync();
}
}
public Choice Status
{
get => _status;
set
{
if (value is null || Equals(value, _status)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_status = value;
OnPropertyChanged();
GameLoad = LoadAsync();
}
}
/// <summary>Recherche locale (titre, référence) parmi les demandes chargées.</summary>
public string SearchText
{
get => _searchText;
set
{
if (SetProperty(ref _searchText, value ?? "")) ApplySearch();
}
}
public ObservableCollection<FeedbackItem> Items { get; } = new();
public FeedbackItem? SelectedItem
{
get => _selectedItem;
set
{
if (value is null || ReferenceEquals(value, _selectedItem)) return;
if (IsBusy || !ConfirmDiscard())
{
OnPropertyChanged();
return;
}
_selectedItem = value;
OnPropertyChanged();
DetailLoad = LoadDetailAsync(value);
}
}
/// <summary>Chargement de la fiche lancé par la dernière sélection (tests).</summary>
internal Task DetailLoad { get; private set; } = Task.CompletedTask;
public FeedbackDetailView? Detail
{
get => _detail;
private set
{
if (!SetProperty(ref _detail, value)) return;
OnPropertyChanged(nameof(HasDetail));
OnPropertyChanged(nameof(ShowNoSelection));
HasConflict = false;
RaiseCommands();
}
}
public bool HasDetail => _detail is not null;
public bool ShowNoSelection => _detail is null && Items.Count > 0;
public bool HasItems => Items.Count > 0;
public override bool HasSplitLayout => HasItems;
public string? EmptyText => _selectedGame is null || IsBusy || Items.Count > 0 ? null
: _loaded.Count > 0 ? "Aucune demande ne correspond à la recherche."
: "Aucune demande pour ces filtres.";
/// <summary>« 12 demandes · 100 affichées ».</summary>
public string CountText => _selectedGame is null ? "" :
$"{FeedbackGameTile.Plural(_total, "demande", "demandes")}" + (_loaded.Count < _total ? $" · {_loaded.Count} chargées" : "");
public bool CanLoadMore => _nextCursor is not null;
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
/// <summary>La demande a été modifiée par un autre administrateur : à recharger avant d'enregistrer.</summary>
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) SaveCommand.RaiseCanExecuteChanged();
}
}
public bool IsEditable => !IsBusy;
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand LoadMoreCommand { get; }
public AsyncRelayCommand SaveCommand { get; }
public AsyncRelayCommand DeleteCommand { get; }
protected override void OnBusyChanged()
{
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(IsGameSwitchEnabled));
OnPropertyChanged(nameof(EmptyText));
RaiseCommands();
}
private void RaiseCommands()
{
ReloadCommand.RaiseCanExecuteChanged();
LoadMoreCommand.RaiseCanExecuteChanged();
SaveCommand.RaiseCanExecuteChanged();
DeleteCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
// ------------------------------------------------------------------ Chargement
/// <summary>Compteurs (tuiles, bandeaux) puis, si un jeu est choisi, la première page de ses demandes.</summary>
public Task LoadAsync() => RunAsync(async () =>
{
var version = ++_loadVersion;
var overview = await Context.Session.GetFeedbackOverviewAsync();
ApplyOverview(overview);
if (_selectedGame?.Value is not { } gameId) return;
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, null, PageSize);
if (version != _loadVersion) return; // jeu ou filtre changé entre-temps
ClearList();
AddPage(page);
});
private async Task ReloadAsync()
{
if (!ConfirmDiscard()) return;
var selectedId = _selectedItem?.Id;
StatusMessage = null;
await LoadAsync();
if (selectedId is not null && Items.FirstOrDefault(i => i.Id == selectedId) is { } again)
{
_selectedItem = again;
OnPropertyChanged(nameof(SelectedItem));
await LoadDetailAsync(again);
}
}
private Task LoadMoreAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId || _nextCursor is null) return;
var version = _loadVersion;
var page = await Context.Session.GetFeedbackPageAsync(gameId, _kind.Value, _status.Value, _nextCursor, PageSize);
if (version != _loadVersion) return;
AddPage(page);
});
private Task LoadDetailAsync(FeedbackItem item) => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId) return;
var detail = await Context.Session.GetFeedbackAsync(gameId, item.Id);
if (!ReferenceEquals(item, _selectedItem)) return; // autre demande choisie entre-temps
item.Summary = detail.Summary;
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));
private void ApplyOverview(FeedbackOverview overview)
{
_overview = overview;
foreach (var (gameId, tile) in _tiles)
tile.Apply(overview.Games.FirstOrDefault(g => string.Equals(g.GameId, gameId, StringComparison.OrdinalIgnoreCase)));
foreach (var name in new[] { nameof(ShowDisabled), nameof(ShowStorageFull), nameof(RetentionText) }) OnPropertyChanged(name);
}
private void ClearList()
{
_loaded.Clear();
_nextCursor = null;
_total = 0;
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ApplySearch();
}
private void AddPage(FeedbackPage page)
{
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s)));
_nextCursor = page.NextCursor;
_total = page.Total;
ApplySearch();
}
private void ApplySearch()
{
Items.Clear();
foreach (var item in _loaded.Where(i => i.Matches(_searchText))) Items.Add(item);
foreach (var name in new[] { nameof(HasItems), nameof(HasSplitLayout), nameof(EmptyText), nameof(CountText), nameof(CanLoadMore), nameof(ShowNoSelection) })
OnPropertyChanged(name);
LoadMoreCommand.RaiseCanExecuteChanged();
}
// ------------------------------------------------------------------ Traitement
private Task SaveAsync() => RunAsync(async () =>
{
if (_selectedGame?.Value is not { } gameId || _detail is not { } detail) return;
StatusMessage = null;
if (FeedbackRules.CleanMultiline(detail.Note, FeedbackRules.MaxAdminNoteLength, out var problem) is null && problem is not null)
{
ErrorMessage = $"Note interne : {problem}";
return;
}
try
{
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();
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
throw;
}
});
private async Task DeleteAsync()
{
if (_detail is not { } detail || _selectedGame?.Value is not { } gameId) return;
if (!Context.Dialogs.Confirm("Supprimer la demande ?",
$"La demande {detail.Reference} (« {detail.Title} ») sera supprimée définitivement du serveur. Cette action est inscrite au journal d'audit.",
"Supprimer", danger: true))
return;
await RunAsync(async () =>
{
await Context.Session.DeleteFeedbackAsync(gameId, detail.Id);
_loaded.RemoveAll(i => i.Id == detail.Id);
_total = Math.Max(0, _total - 1);
_selectedItem = null;
OnPropertyChanged(nameof(SelectedItem));
Detail = null;
ApplySearch();
StatusMessage = $"Demande {detail.Reference} supprimée.";
await RefreshCountsAsync();
});
}
/// <summary>Compteurs des tuiles après un changement (sans effet sur la page en cas d'échec).</summary>
private async Task RefreshCountsAsync()
{
try
{
ApplyOverview(await Context.Session.GetFeedbackOverviewAsync());
}
catch (ApiException)
{
// compteurs indicatifs : le prochain rechargement les mettra à jour
}
}
/// <summary>Statut ou note modifiés et non enregistrés : confirmation avant de les abandonner.</summary>
private bool ConfirmDiscard() =>
_detail is not { HasChanges: true } ||
Context.Dialogs.Confirm("Abandonner les modifications ?",
"Le statut ou la note interne de la demande affichée n'ont pas été enregistrés : ils seront perdus.", "Abandonner");
}
@@ -14,7 +14,7 @@ public sealed class AdminLoginService
private readonly IAdminTpm? _tpm;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="clientName">Logiciel pour le journal d'audit, ex. « GameLauncher 2.5.0 » (l'appareil est identifié par sa clé).</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
@@ -59,6 +59,10 @@ public sealed class AdminEnvironment
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
public bool StartTimers { get; init; } = true;
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
/// <summary>
/// Logiciel, pour le journal d'audit du serveur (« GameLauncher 2.5.0 »). Le poste y est identifié par le nom de
/// l'appareil choisi à l'enregistrement de sa clé (invitation), que le serveur connaît : jamais par le nom Windows du
/// PC, qui n'est pas envoyé.
/// </summary>
public string ClientName => $"GameLauncher {LauncherVersion}";
}
@@ -1,5 +1,7 @@
using GameLauncher.Contracts.Security;
using GameLauncher.ApiClient;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Services;
using GameLauncher.Mvvm;
@@ -31,6 +33,55 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
RefreshCommand = new AsyncRelayCommand(ScrollingToTop(RefreshAsync), () => !IsBusy);
OpenAdminsCommand = new RelayCommand(() => Context.Navigate(AdminsPanelViewModel.PanelId), () => CanOpenAdmins);
OpenDevicesCommand = new RelayCommand(() => Context.Navigate(MyDevicesPanelViewModel.PanelId), () => CanOpenDevices);
OpenFeedbackCommand = new RelayCommand(() => Context.Navigate(FeedbackPanelViewModel.PanelId), () => CanReadFeedback);
}
// ------------------------------------------------------------------ Demandes des joueurs
/// <summary>Carte « Demandes des joueurs » : permission feedback.read et page présente.</summary>
public bool CanReadFeedback => Context.Session.Has(AdminPermissions.FeedbackRead) &&
Context.Panels.Any(p => p.Id == FeedbackPanelViewModel.PanelId);
public RelayCommand OpenFeedbackCommand { get; }
private string _feedbackText = "…";
private bool _hasNewFeedback;
/// <summary>« Exovival : 3 nouvelles · Autre jeu : aucune » (ou la raison d'une indisponibilité).</summary>
public string FeedbackText
{
get => _feedbackText;
private set => SetProperty(ref _feedbackText, value);
}
public bool HasNewFeedback
{
get => _hasNewFeedback;
private set => SetProperty(ref _hasNewFeedback, value);
}
private async Task RefreshFeedbackAsync()
{
try
{
var overview = await Context.Session.GetFeedbackOverviewAsync();
HasNewFeedback = overview.Games.Any(g => g.New > 0);
if (!overview.Enabled)
{
FeedbackText = "Réception désactivée sur le serveur (LauncherApi:Feedback:Enabled).";
return;
}
var names = Context.Games.ToDictionary(g => g.GameId, g => g.GameName, StringComparer.OrdinalIgnoreCase);
var parts = overview.Games.Select(g =>
$"{(names.TryGetValue(g.GameId, out var name) ? name : g.GameId)} : " +
(g.New == 0 ? "aucune nouvelle" : g.New == 1 ? "1 nouvelle" : $"{g.New} nouvelles") + $" ({g.Open} à traiter)").ToList();
FeedbackText = (parts.Count == 0 ? "Aucun jeu ne reçoit de demandes." : string.Join(" · ", parts)) +
(overview.StorageFull ? " · plafond atteint : nouvelles demandes refusées" : "");
}
catch (ApiException ex) when (!ApiErrorText.IsSessionLost(ex))
{
FeedbackText = ex.Status == 404 ? "Serveur à mettre à jour pour les demandes des joueurs." : ApiErrorText.Describe(ex);
}
}
/// <summary>Pages « Administrateurs » et « Mes appareils » présentes (modules déclarés, droits).</summary>
@@ -130,6 +181,7 @@ public sealed class DashboardPanelViewModel : AdminPanelViewModel
.SelectMany(a => a.Keys).Count(k => k.Status == AdminStatus.Pending);
ActiveInvitations = invitations.Count;
}
if (CanReadFeedback) await RefreshFeedbackAsync();
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; }
}
@@ -0,0 +1,140 @@
using GameLauncher.Contracts.Feedback;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Feedback;
/// <summary>Étoile d'un champ « note » (1 à 5).</summary>
public sealed class RatingStar : ObservableObject
{
private bool _isOn;
public RatingStar(int value, Action<int> select)
{
Value = value;
SelectCommand = new RelayCommand(() => select(value));
}
public int Value { get; }
public string ToolTip => Value switch
{
1 => "1 étoile : décevant",
2 => "2 étoiles : moyen",
3 => "3 étoiles : bien",
4 => "4 étoiles : très bien",
_ => "5 étoiles : excellent"
};
public bool IsOn
{
get => _isOn;
set => SetProperty(ref _isOn, value);
}
public RelayCommand SelectCommand { get; }
}
/// <summary>Champ du formulaire, généré à partir de sa description partagée (<see cref="FeedbackField"/>).</summary>
public sealed class FeedbackFieldViewModel : ObservableObject
{
/// <summary>Choix « non précisé » des listes facultatives.</summary>
public static readonly FeedbackOption NoChoice = new("", "Non précisé");
private string _text = "";
private FeedbackOption? _selectedOption;
private int _rating;
private string? _error;
public FeedbackFieldViewModel(FeedbackField field)
{
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();
}
public FeedbackField Field { get; }
public string Id => Field.Id;
public string Label => Field.Required ? Field.Label + " *" : Field.Label;
public string? Hint => Field.Hint;
public bool IsMultiline => Field.Kind == FeedbackFieldKind.Multiline;
public bool IsChoice => Field.Kind == FeedbackFieldKind.Choice;
public bool IsRating => Field.Kind == FeedbackFieldKind.Rating;
public int MaxLength => Field.MaxLength;
public string Text
{
get => _text;
set
{
if (!SetProperty(ref _text, value ?? "")) return;
OnPropertyChanged(nameof(LengthText));
OnPropertyChanged(nameof(IsTooLong));
Error = null;
}
}
/// <summary>« 120 / 2000 » (texte), affiché sous la zone de saisie.</summary>
public string LengthText => $"{_text.Trim().Length} / {MaxLength}";
public bool IsTooLong => _text.Trim().Length > MaxLength;
public IReadOnlyList<FeedbackOption> Options { get; }
public FeedbackOption? SelectedOption
{
get => _selectedOption;
set
{
if (SetProperty(ref _selectedOption, value)) Error = null;
}
}
public IReadOnlyList<RatingStar> Stars { get; }
public int Rating
{
get => _rating;
set
{
if (!SetProperty(ref _rating, Math.Clamp(value, 0, 5))) return;
foreach (var star in Stars) star.IsOn = star.Value <= _rating;
OnPropertyChanged(nameof(RatingText));
Error = null;
}
}
public string RatingText => _rating == 0 ? "Choisissez une note" : Stars[_rating - 1].ToolTip;
/// <summary>Erreur propre à ce champ (affichée sous le champ).</summary>
public string? Error
{
get => _error;
set
{
if (SetProperty(ref _error, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(_error);
/// <summary>Valeur envoyée (null : vide).</summary>
public string? Value => Field.Kind switch
{
FeedbackFieldKind.Choice => string.IsNullOrEmpty(_selectedOption?.Id) ? null : _selectedOption!.Id,
FeedbackFieldKind.Rating => _rating == 0 ? null : _rating.ToString(System.Globalization.CultureInfo.InvariantCulture),
_ => string.IsNullOrWhiteSpace(_text) ? null : _text
};
/// <summary>Contrôle local (mêmes règles que le serveur). Vrai si le champ est valide.</summary>
public bool Check()
{
var errors = new List<string>();
FeedbackRules.CheckField(Field, Value, errors);
Error = errors.Count == 0 ? null : errors[0].StartsWith(Field.Label + " : ", StringComparison.Ordinal)
? char.ToUpperInvariant(errors[0][(Field.Label.Length + 3)]) + errors[0][(Field.Label.Length + 4)..]
: errors[0];
return errors.Count == 0;
}
}
@@ -0,0 +1,455 @@
using System.Collections.ObjectModel;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Feedback;
/// <summary>Jeu concerné par la demande : celui sélectionné dans le launcher à l'ouverture (figé ensuite).</summary>
/// <param name="InstalledVersion">Version installée (null : jeu non installé).</param>
public sealed record FeedbackTarget(string GameId, string GameName, string? InstalledVersion, string LauncherVersion);
/// <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<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>
public sealed class ApiFeedbackSender : IFeedbackSender
{
private readonly LauncherApiClient _client;
public ApiFeedbackSender(LauncherApiClient client) => _client = client;
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>
public sealed class FeedbackKindChoice
{
public FeedbackKindChoice(FeedbackForm form, Action<FeedbackForm> select)
{
Form = form;
SelectCommand = new RelayCommand(() => select(form));
}
public FeedbackForm Form { get; }
public string Label => Form.Label;
public string Description => Form.Description;
public string IconKey => Form.IconKey;
public RelayCommand SelectCommand { get; }
}
/// <summary>Étapes de la fenêtre « Aide ».</summary>
public enum FeedbackStep
{
ChooseKind,
Fill,
Done
}
/// <summary>
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires partagés), envoi, référence.
/// • le jeu est celui sélectionné à l'ouverture, affiché et non modifiable ;
/// • 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.
/// </summary>
public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
private readonly IFeedbackSender _sender;
private readonly IScreenshotSource? _screenshots;
private string? _attachmentError;
private string? _attachmentWarning;
private readonly FeedbackTechInfo _tech;
private CancellationTokenSource? _sending;
private FeedbackStep _step = FeedbackStep.ChooseKind;
private FeedbackForm? _form;
private string _title = "";
private string? _titleError;
private bool _includeTech;
private bool _isSending;
private string? _errorMessage;
private string? _reference;
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null,
IScreenshotSource? screenshots = null)
{
Target = target;
_sender = sender;
_screenshots = screenshots;
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());
}
public FeedbackTarget Target { get; }
/// <summary>« Demande pour : Exovival Zombie Reborn ».</summary>
public string GameText => $"Demande pour : {Target.GameName}";
public IReadOnlyList<FeedbackKindChoice> Kinds { get; }
/// <summary>La fenêtre doit se fermer (« Fermer », « Annuler »).</summary>
public event Action? CloseRequested;
// ------------------------------------------------------------------ Étapes
public FeedbackStep Step
{
get => _step;
private set
{
if (!SetProperty(ref _step, value)) return;
OnPropertyChanged(nameof(IsChoosingKind));
OnPropertyChanged(nameof(IsFilling));
OnPropertyChanged(nameof(IsDone));
RaiseCommands();
}
}
public bool IsChoosingKind => _step == FeedbackStep.ChooseKind;
public bool IsFilling => _step == FeedbackStep.Fill;
public bool IsDone => _step == FeedbackStep.Done;
public FeedbackForm? Form
{
get => _form;
private set
{
if (!SetProperty(ref _form, value)) return;
OnPropertyChanged(nameof(FormTitle));
OnPropertyChanged(nameof(FormIconKey));
OnPropertyChanged(nameof(TitleHint));
}
}
public string FormTitle => _form?.Label ?? "Aide";
public string FormIconKey => _form?.IconKey ?? "MessageQuestion";
public string TitleHint => _form?.TitleHint ?? "";
private void Choose(FeedbackForm form)
{
if (_isSending) return;
if (!ReferenceEquals(form, _form))
{
Form = form;
Fields.Clear();
foreach (var field in form.Fields) Fields.Add(new FeedbackFieldViewModel(field));
IncludeTech = form.TechByDefault;
TitleError = null;
ErrorMessage = null;
}
Step = FeedbackStep.Fill;
}
/// <summary>Retour au choix du type (la saisie de ce type est gardée tant qu'on y revient).</summary>
private void Back()
{
ErrorMessage = null;
Step = FeedbackStep.ChooseKind;
}
// ------------------------------------------------------------------ Saisie
public string Title
{
get => _title;
set
{
if (!SetProperty(ref _title, value ?? "")) return;
TitleError = null;
OnPropertyChanged(nameof(TitleLengthText));
}
}
public int TitleMaxLength => FeedbackRules.MaxTitleLength;
public string TitleLengthText => $"{_title.Trim().Length} / {FeedbackRules.MaxTitleLength}";
public string? TitleError
{
get => _titleError;
private set
{
if (SetProperty(ref _titleError, value)) OnPropertyChanged(nameof(HasTitleError));
}
}
public bool HasTitleError => !string.IsNullOrEmpty(_titleError);
public ObservableCollection<FeedbackFieldViewModel> Fields { get; } = new();
/// <summary>Joindre les informations techniques (cochée par défaut pour un bug).</summary>
public bool IncludeTech
{
get => _includeTech;
set => SetProperty(ref _includeTech, value);
}
/// <summary>Exactement ce qui sera joint si la case est cochée.</summary>
public string TechPreview => string.Join("\n", new[]
{
_tech.LauncherVersion is { } launcher ? $"Version du launcher : {launcher}" : null,
$"Version du jeu : {_tech.GameVersion ?? "non installé"}",
_tech.Os is { } os ? $"Système : {os}" : null,
_tech.Language is { } language ? $"Langue : {language}" : null
}.Where(l => l is not null));
public string PrivacyText =>
"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;
public bool HasAttachments => Attachments.Count > 0;
public bool CanAddScreenshot => _screenshots is not null && !_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; }
public RelayCommand BackCommand { get; }
public RelayCommand CloseCommand { get; }
public bool IsSending
{
get => _isSending;
private set
{
if (!SetProperty(ref _isSending, value)) return;
OnPropertyChanged(nameof(IsEditable));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
PasteCommand.RaiseCanExecuteChanged();
RaiseCommands();
}
}
/// <summary>Saisie suspendue pendant l'envoi.</summary>
public bool IsEditable => !_isSending;
public string? ErrorMessage
{
get => _errorMessage;
private set
{
if (SetProperty(ref _errorMessage, value)) OnPropertyChanged(nameof(HasError));
}
}
public bool HasError => !string.IsNullOrEmpty(_errorMessage);
/// <summary>Référence de la demande envoyée (« FB-7K3Q-9P2M »).</summary>
public string? Reference
{
get => _reference;
private set => SetProperty(ref _reference, value);
}
/// <summary>Contrôle local de toute la saisie. Vrai si tout est valide.</summary>
public bool Validate()
{
var ok = true;
var title = Contracts.ApiText.CleanLine(_title, FeedbackRules.MaxTitleLength);
if (title is null || title.Length < FeedbackRules.MinTitleLength)
{
TitleError = string.IsNullOrWhiteSpace(_title)
? "Titre obligatoire."
: $"{FeedbackRules.MinTitleLength} à {FeedbackRules.MaxTitleLength} caractères sur une ligne.";
ok = false;
}
foreach (var field in Fields) ok &= field.Check();
return ok;
}
/// <summary>Demande telle qu'elle sera envoyée (null si la saisie est invalide).</summary>
public FeedbackSubmission? BuildSubmission()
{
if (_form is null) return null;
var submission = new FeedbackSubmission
{
GameId = Target.GameId,
Kind = _form.Kind,
FormVersion = FeedbackForms.CurrentVersion,
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);
if (clean is null) ErrorMessage = errors.FirstOrDefault();
return clean;
}
private async Task SendAsync()
{
if (_form is null || _isSending) return;
ErrorMessage = null;
if (!Validate())
{
ErrorMessage = "Vérifiez les champs signalés.";
return;
}
if (BuildSubmission() is not { } submission) return;
IsSending = true;
_sending = new CancellationTokenSource();
try
{
var sent = await _sender.SendAsync(submission, Attachments.Select(a => a.Content).ToList(), _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 (Exception ex)
{
Log.Warn($"[{Target.GameId}] Envoi d'une demande impossible : {ex.Message}");
ErrorMessage = Describe(ex);
}
finally
{
_sending.Dispose();
_sending = null;
IsSending = false;
}
}
/// <summary>Message clair pour le joueur (jamais de détail technique du serveur d'administration).</summary>
public static string Describe(Exception ex) => ex switch
{
ApiException { Status: 0 } =>
"Impossible de joindre le serveur. Vérifiez votre connexion Internet, puis réessayez : votre saisie est conservée.",
ApiException { Status: 429 } api => api.Message is { Length: > 0 } && api.Code == ApiErrorCodes.RateLimited
? api.Message
: "Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.",
// 404 : serveur pas encore mis à jour (route absente) ; pour le joueur, c'est la même situation.
ApiException { Code: ApiErrorCodes.NotConfigured } or ApiException { Status: 404 } =>
"L'envoi des demandes n'est pas disponible pour le moment. Réessayez plus tard.",
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 { Status: 400 } api => api.Message,
_ => "L'envoi a échoué. Réessayez dans un instant : votre saisie est conservée."
};
private void RaiseCommands()
{
SendCommand.RaiseCanExecuteChanged();
BackCommand.RaiseCanExecuteChanged();
}
/// <summary>Fermeture de la fenêtre : un envoi en cours est abandonné.</summary>
public void Dispose()
{
try
{
_sending?.Cancel();
}
catch (ObjectDisposedException)
{
// envoi déjà terminé
}
}
}
@@ -0,0 +1,32 @@
using System.Globalization;
using System.Runtime.InteropServices;
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Modules.Feedback;
/// <summary>
/// Informations techniques jointes à une demande (si le joueur laisse la case cochée) : versions du launcher et du jeu,
/// version de Windows, langue. RIEN d'identifiant : ni nom du PC ni de l'utilisateur, ni chemin, ni matériel.
/// Chaque valeur est contrôlée avec les formats du serveur (une valeur inattendue est simplement omise).
/// </summary>
public static class FeedbackTech
{
public static FeedbackTechInfo Collect(string? launcherVersion, string? gameVersion) => new()
{
LauncherVersion = FeedbackRules.SafeVersion(launcherVersion),
GameVersion = FeedbackRules.SafeVersion(gameVersion),
Os = FeedbackRules.SafeOs(DescribeOs()),
Language = FeedbackRules.SafeLanguage(CultureInfo.CurrentUICulture.Name)
};
/// <summary>« Windows 11 64 bits (10.0.26100) ».</summary>
public static string DescribeOs()
{
var version = Environment.OSVersion.Version;
var bits = Environment.Is64BitOperatingSystem ? "64 bits" : "32 bits";
if (!OperatingSystem.IsWindows())
return $"{RuntimeInformation.OSDescription.Split(' ', 2)[0]} {bits}";
var name = version.Major == 10 && version.Build >= 22000 ? "Windows 11" : version.Major == 10 ? "Windows 10" : "Windows";
return $"{name} {bits} ({version.Major}.{version.Minor}.{version.Build})";
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
Module « Aide » côté joueur (sans WPF) : formulaire de demande (avis, demande d'évolution, bug, autre) généré à partir
des formulaires partagés (GameLauncher.Contracts.Feedback), contrôlé avec les mêmes règles que le serveur, puis envoyé
SANS compte à la Launcher API. La fenêtre correspondante est dans le launcher (Views\FeedbackWindow.xaml).
La page d'administration « Demandes des joueurs » est dans GameLauncher.Modules.Admin (Feedback).
Voir docs\ADMIN-ARCHITECTURE.md, § 11 quinquies.
-->
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>GameLauncher.Modules.Feedback</RootNamespace>
<AssemblyName>GameLauncher.Modules.Feedback</AssemblyName>
<Platforms>AnyCPU;x64</Platforms>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="GameLauncher.Tests" />
</ItemGroup>
</Project>
+10
View File
@@ -18,6 +18,7 @@ using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Files;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
@@ -255,6 +256,9 @@ public partial class App : Application, ILauncherRestartHost
var version = VersionUtil.Display(_version);
Log.Info($"Démarrage du launcher {version} ({catalog.Games.Count} jeu(x))");
// Mode d'affichage (plein écran ou fenêtré) mémorisé pour chaque fenêtre qui le permet. Pas en mode --capture.
Controls.WindowModeAssist.Store = readOnly ? null : new WindowModeStore(Path.Combine(catalog.DataDirectory, WindowModeStore.FileName));
var window = new Views.MainWindow();
MainWindow = window;
_mainWindow = window;
@@ -359,6 +363,11 @@ public partial class App : Application, ILauncherRestartHost
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList(), _playerSigning);
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);
launcher.IsHelpAvailable = feedback.IsAvailable;
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
}
window.DataContext = launcher;
@@ -570,6 +579,7 @@ public partial class App : Application, ILauncherRestartHost
{
new AdminModule(),
new CatalogAdminModule(),
new FeedbackAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
+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).
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace GameLauncher.Controls;
/// <summary>
/// Mise en page responsive d'une fenêtre à deux modes (fenêtré / plein écran, voir <see cref="WindowModeAssist"/>) :
/// <list type="bullet">
/// <item>place disponible plus GRANDE que la taille minimale de mise en page (<see cref="DesignWidth"/> ×
/// <see cref="DesignHeight"/>, un peu en dessous de la taille fenêtrée : le contenu défile déjà verticalement) : le contenu
/// occupe toute la place (colonnes, listes et ascenseurs s'adaptent) ;</item>
/// <item>place plus PETITE (petit écran, mise à l'échelle Windows 125-150 %) : le contenu est mis en page à la taille de
/// conception puis réduit d'un bloc, sans rien rogner ni masquer : tous les boutons restent accessibles, tous les textes
/// visibles.</item>
/// </list>
/// Le contenu n'est jamais agrandi : en plein écran sur un grand écran, il gagne de la place, pas des pixels flous.
/// </summary>
public sealed class ScaleToFitDecorator : Decorator
{
public static readonly DependencyProperty DesignWidthProperty = DependencyProperty.Register(
nameof(DesignWidth), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
public static readonly DependencyProperty DesignHeightProperty = DependencyProperty.Register(
nameof(DesignHeight), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
/// <summary>Largeur minimale de mise en page du contenu (0 : jamais réduit en largeur).</summary>
public double DesignWidth
{
get => (double)GetValue(DesignWidthProperty);
set => SetValue(DesignWidthProperty, value);
}
/// <summary>Hauteur minimale de mise en page du contenu (0 : jamais réduit en hauteur).</summary>
public double DesignHeight
{
get => (double)GetValue(DesignHeightProperty);
set => SetValue(DesignHeightProperty, value);
}
/// <summary>Échelle appliquée (1 : taille réelle ; en dessous, contenu réduit).</summary>
public double Scale { get; private set; } = 1;
/// <summary>Plus petite échelle acceptée : en dessous, les ascenseurs du contenu prennent le relais.</summary>
private const double MinScale = 0.5;
/// <summary>Écart négligeable (arrondis) : pas de réduction, qui rendrait le texte un peu flou pour rien.</summary>
private const double Tolerance = 0.995;
private static bool IsValidSize(object value) => value is double d && !double.IsNaN(d) && !double.IsInfinity(d) && d >= 0;
private double ScaleFor(Size available)
{
var scale = 1.0;
if (DesignWidth > 0 && !double.IsInfinity(available.Width) && available.Width < DesignWidth)
scale = Math.Min(scale, available.Width / DesignWidth);
if (DesignHeight > 0 && !double.IsInfinity(available.Height) && available.Height < DesignHeight)
scale = Math.Min(scale, available.Height / DesignHeight);
return scale >= Tolerance ? 1 : Math.Max(MinScale, scale);
}
protected override Size MeasureOverride(Size constraint)
{
if (Child is not { } child) return default;
var scale = ScaleFor(constraint);
child.Measure(new Size(constraint.Width / scale, constraint.Height / scale));
var desired = child.DesiredSize;
return new Size(Math.Min(desired.Width * scale, constraint.Width), Math.Min(desired.Height * scale, constraint.Height));
}
protected override Size ArrangeOverride(Size arrangeSize)
{
if (Child is not { } child) return arrangeSize;
var scale = ScaleFor(arrangeSize);
Scale = scale;
// Mis en page à la taille « non réduite », puis réduit à l'affichage (le clic et les infobulles suivent la réduction).
child.Arrange(new Rect(0, 0, arrangeSize.Width / scale, arrangeSize.Height / scale));
if (scale >= 1)
{
if (child.RenderTransform is not MatrixTransform { Matrix.IsIdentity: true }) child.RenderTransform = Transform.Identity;
}
else if (child.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0001)
{
child.RenderTransform = new ScaleTransform(scale, scale);
}
return arrangeSize;
}
}
@@ -0,0 +1,363 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using GameLauncher.Platform;
namespace GameLauncher.Controls;
/// <summary>
/// Deux modes d'affichage seulement pour une fenêtre sans bordure : FENÊTRÉ (taille d'origine, jamais redimensionnable
/// par les bords ou les coins) ou PLEIN ÉCRAN, au sens d'une fenêtre Windows agrandie : toute la zone utile de l'écran où
/// se trouve la fenêtre, la barre des tâches restant visible (même en masquage automatique). Ce n'est pas le plein écran
/// exclusif d'un jeu.
/// <para>
/// Une fenêtre s'y inscrit en XAML, sans code : <c>ui:WindowModeAssist.AllowFullScreen="True"</c> (et, pour retrouver
/// le mode au prochain affichage, <c>ui:WindowModeAssist.PersistenceKey="clé"</c>). Elle reçoit alors :
/// </para>
/// <list type="bullet">
/// <item>la touche F11 et la commande <see cref="ToggleFullScreenCommand"/> (bouton prêt à l'emploi : style
/// « WindowModeButtonStyle » de Themes/Controls.xaml, icône et infobulle selon le mode) ;</item>
/// <item>les ressources « WindowChrome.* » de la fenêtre (CornerRadius, Thickness : arrondis, marge de l'ombre) mises à 0
/// en plein écran, puis restaurées : les éléments qui les utilisent en DynamicResource suivent le mode ;</item>
/// <item>en mode fenêtré, une taille limitée à la zone utile de SON écran (petits écrans, mise à l'échelle 125-150 %).</item>
/// </list>
/// Les fenêtres qui ne s'y inscrivent pas (boîtes de dialogue, paramètres…) gardent leur taille fixe.
/// </summary>
public static class WindowModeAssist
{
/// <summary>Bascule fenêtré / plein écran de la fenêtre qui contient l'élément (F11).</summary>
public static readonly RoutedUICommand ToggleFullScreenCommand =
new("Plein écran", nameof(ToggleFullScreenCommand), typeof(WindowModeAssist));
/// <summary>Préfixe des ressources de la fenêtre (CornerRadius, Thickness) remises à zéro en plein écran.</summary>
public const string ChromeResourcePrefix = "WindowChrome.";
/// <summary>
/// Modes mémorisés d'une fenêtre à l'autre (<see cref="PersistenceKeyProperty"/>). Null : rien n'est mémorisé
/// (mode --capture, tests). Fixé par l'application avant l'affichage des fenêtres.
/// </summary>
public static WindowModeStore? Store { get; set; }
// ------------------------------------------------------------------ Propriétés attachées
public static readonly DependencyProperty AllowFullScreenProperty = DependencyProperty.RegisterAttached(
"AllowFullScreen", typeof(bool), typeof(WindowModeAssist), new PropertyMetadata(false, OnAllowFullScreenChanged));
public static bool GetAllowFullScreen(DependencyObject element) => (bool)element.GetValue(AllowFullScreenProperty);
public static void SetAllowFullScreen(DependencyObject element, bool value) => element.SetValue(AllowFullScreenProperty, value);
/// <summary>Mode courant de la fenêtre (liable ; les déclencheurs des styles s'en servent).</summary>
public static readonly DependencyProperty IsFullScreenProperty = DependencyProperty.RegisterAttached(
"IsFullScreen", typeof(bool), typeof(WindowModeAssist),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsFullScreenChanged));
public static bool GetIsFullScreen(DependencyObject element) => (bool)element.GetValue(IsFullScreenProperty);
public static void SetIsFullScreen(DependencyObject element, bool value) => element.SetValue(IsFullScreenProperty, value);
/// <summary>Clé sous laquelle le mode de la fenêtre est mémorisé (« main », « admin »…) ; vide : non mémorisé.</summary>
public static readonly DependencyProperty PersistenceKeyProperty = DependencyProperty.RegisterAttached(
"PersistenceKey", typeof(string), typeof(WindowModeAssist), new PropertyMetadata(null));
public static string? GetPersistenceKey(DependencyObject element) => (string?)element.GetValue(PersistenceKeyProperty);
public static void SetPersistenceKey(DependencyObject element, string? value) => element.SetValue(PersistenceKeyProperty, value);
// État interne d'une fenêtre inscrite.
private static readonly DependencyProperty StateProperty = DependencyProperty.RegisterAttached(
"State", typeof(WindowModeState), typeof(WindowModeAssist), new PropertyMetadata(null));
private sealed class WindowModeState
{
public readonly Dictionary<object, object> WindowedChrome = new();
public bool Syncing;
}
// ------------------------------------------------------------------ API pour le code
/// <summary>Bascule le mode d'une fenêtre inscrite (sans effet sinon).</summary>
public static void Toggle(Window window)
{
if (GetAllowFullScreen(window)) SetIsFullScreen(window, !GetIsFullScreen(window));
}
/// <summary>
/// Réaffiche une fenêtre réduite dans SON mode (plein écran ou fenêtré) : à utiliser à la place de
/// <c>WindowState = Normal</c>, qui ferait quitter le plein écran.
/// </summary>
public static void RestoreFromMinimized(Window window)
{
if (window.WindowState != WindowState.Minimized) return;
window.WindowState = GetAllowFullScreen(window) && GetIsFullScreen(window) ? WindowState.Maximized : WindowState.Normal;
}
/// <summary>Déplacement par la barre de titre : seulement en mode fenêtré (en plein écran, la fenêtre reste en place).</summary>
public static void DragMove(Window window)
{
if (window.WindowState != WindowState.Normal) return;
try
{
window.DragMove();
}
catch (InvalidOperationException)
{
// bouton de la souris déjà relâché
}
}
/// <summary>Zone utile (hors barre des tâches) de l'écran où se trouve l'élément, en pixels indépendants (DIP).</summary>
public static Rect WorkArea(Visual? reference)
{
if (reference is not null && PresentationSource.FromVisual(reference) is HwndSource { Handle: var handle } source &&
handle != IntPtr.Zero && source.CompositionTarget is { } target)
{
var monitor = MonitorFromWindow(handle, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor != IntPtr.Zero && GetMonitorInfo(monitor, ref info))
{
var fromDevice = target.TransformFromDevice;
var topLeft = fromDevice.Transform(new Point(info.Work.Left, info.Work.Top));
var bottomRight = fromDevice.Transform(new Point(info.Work.Right, info.Work.Bottom));
return new Rect(topLeft, bottomRight);
}
}
return SystemParameters.WorkArea;
}
// ------------------------------------------------------------------ Mise en place
private static void OnAllowFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window) return;
if (e.NewValue is true)
{
if (window.GetValue(StateProperty) is not null) return;
window.SetValue(StateProperty, new WindowModeState());
window.CommandBindings.Add(new CommandBinding(ToggleFullScreenCommand, (_, args) =>
{
Toggle(window);
args.Handled = true;
}, (_, args) =>
{
args.CanExecute = true;
args.Handled = true;
}));
window.InputBindings.Add(new KeyBinding(ToggleFullScreenCommand, Key.F11, ModifierKeys.None));
window.SourceInitialized += OnSourceInitialized;
window.StateChanged += OnStateChanged;
}
else
{
SetIsFullScreen(window, false);
}
}
/// <summary>Juste avant le premier affichage : taille adaptée à l'écran, puis mode mémorisé.</summary>
private static void OnSourceInitialized(object? sender, EventArgs e)
{
if (sender is not Window window) return;
// Une fenêtre sans bordure agrandie couvrirait par défaut tout l'écran, barre des tâches comprise :
// sa taille agrandie est ramenée à la zone utile de son écran (WM_GETMINMAXINFO).
if (PresentationSource.FromVisual(window) is HwndSource source) source.AddHook(LimitMaximizedToWorkArea);
FitToWorkArea(window);
if (GetPersistenceKey(window) is { Length: > 0 } key && Store?.Get(key) == true) SetIsFullScreen(window, true);
}
/// <summary>
/// Mode fenêtré : jamais plus grande que la zone utile de son écran, et entièrement visible. Une fenêtre placée
/// volontairement hors des écrans (captures de tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private static void FitToWorkArea(Window window)
{
var work = WorkArea(window);
if (work.Width < 1 || work.Height < 1) return;
if (window.Width > work.Width) window.Width = work.Width;
if (window.Height > work.Height) window.Height = work.Height;
if (double.IsNaN(window.Left) || double.IsNaN(window.Top)) return;
if (!work.IntersectsWith(new Rect(window.Left, window.Top, window.Width, window.Height))) return;
if (window.Left + window.Width > work.Right) window.Left = work.Right - window.Width;
if (window.Top + window.Height > work.Bottom) window.Top = work.Bottom - window.Height;
if (window.Left < work.Left) window.Left = work.Left;
if (window.Top < work.Top) window.Top = work.Top;
}
// ------------------------------------------------------------------ Changement de mode
private static void OnIsFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window || window.GetValue(StateProperty) is not WindowModeState state) return;
var fullScreen = e.NewValue is true;
if (fullScreen && !GetAllowFullScreen(window))
{
SetIsFullScreen(window, false);
return;
}
ApplyChrome(window, state, fullScreen);
if (!state.Syncing)
{
state.Syncing = true;
try
{
// Fenêtre réduite : le mode s'appliquera à sa réapparition (RestoreFromMinimized).
if (window.WindowState != WindowState.Minimized)
window.WindowState = fullScreen ? WindowState.Maximized : WindowState.Normal;
}
finally
{
state.Syncing = false;
}
}
if (GetPersistenceKey(window) is { Length: > 0 } key) Store?.Set(key, fullScreen);
}
/// <summary>
/// État changé par Windows (Win+Flèche bas, réduction depuis la barre des tâches…) : le mode suit. Une fenêtre réduite
/// garde son mode ; une fenêtre agrandie n'existe qu'en plein écran (pas d'agrandissement partiel).
/// </summary>
private static void OnStateChanged(object? sender, EventArgs e)
{
if (sender is not Window window || window.GetValue(StateProperty) is not WindowModeState state || state.Syncing) return;
if (window.WindowState == WindowState.Minimized) return;
var fullScreen = window.WindowState == WindowState.Maximized;
if (fullScreen == GetIsFullScreen(window)) return;
state.Syncing = true;
try
{
SetIsFullScreen(window, fullScreen);
}
finally
{
state.Syncing = false;
}
}
/// <summary>Ressources « WindowChrome.* » de la fenêtre : 0 en plein écran, valeurs d'origine en mode fenêtré.</summary>
private static void ApplyChrome(Window window, WindowModeState state, bool fullScreen)
{
if (fullScreen)
{
foreach (var key in window.Resources.Keys.OfType<string>().Where(k => k.StartsWith(ChromeResourcePrefix, StringComparison.Ordinal)).ToList())
{
var value = window.Resources[key];
object? flat = value switch
{
CornerRadius => new CornerRadius(0),
Thickness => new Thickness(0),
_ => null
};
if (flat is null) continue;
state.WindowedChrome.TryAdd(key, value);
window.Resources[key] = flat;
}
}
else
{
foreach (var (key, value) in state.WindowedChrome) window.Resources[key] = value;
state.WindowedChrome.Clear();
}
}
// ------------------------------------------------------------------ Agrandie = zone utile de l'écran
private const int WmGetMinMaxInfo = 0x0024;
/// <summary>Marge laissée sur le bord d'une barre des tâches masquée automatiquement, pour qu'elle puisse réapparaître.</summary>
private const int AutoHideMargin = 2;
/// <summary>
/// Taille et position de la fenêtre agrandie : la zone utile de l'écran qui la contient (hors barre des tâches et autres
/// barres d'application). Barre des tâches en masquage automatique : un liseré de 2 pixels lui est laissé sur son bord,
/// sinon la souris ne pourrait plus la faire apparaître.
/// </summary>
private static IntPtr LimitMaximizedToWorkArea(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero) return IntPtr.Zero;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) return IntPtr.Zero;
var work = info.Work;
if (work.Left == info.Monitor.Left && work.Top == info.Monitor.Top &&
work.Right == info.Monitor.Right && work.Bottom == info.Monitor.Bottom)
{
// Aucune place réservée : barre des tâches absente de cet écran, ou masquée automatiquement.
if (HasAutoHideBar(AppBarEdgeLeft, info.Monitor)) work.Left += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeTop, info.Monitor)) work.Top += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeRight, info.Monitor)) work.Right -= AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeBottom, info.Monitor)) work.Bottom -= AutoHideMargin;
}
var minMax = Marshal.PtrToStructure<MinMaxInfo>(lParam);
// Position relative au coin de l'écran (Windows l'applique à l'écran où la fenêtre est agrandie).
minMax.MaxPosition = new NativePoint { X = work.Left - info.Monitor.Left, Y = work.Top - info.Monitor.Top };
minMax.MaxSize = new NativePoint { X = work.Right - work.Left, Y = work.Bottom - work.Top };
Marshal.StructureToPtr(minMax, lParam, false);
handled = true;
return IntPtr.Zero;
}
private static bool HasAutoHideBar(int edge, NativeRect monitor)
{
var data = new AppBarData { Size = Marshal.SizeOf<AppBarData>(), Edge = edge, Rect = monitor };
return SHAppBarMessage(AppBarGetAutoHideBarEx, ref data) != IntPtr.Zero;
}
// ------------------------------------------------------------------ Win32
private const int MonitorDefaultToNearest = 2;
private const int AppBarGetAutoHideBarEx = 0x0000000B;
private const int AppBarEdgeLeft = 0, AppBarEdgeTop = 1, AppBarEdgeRight = 2, AppBarEdgeBottom = 3;
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X, Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct MinMaxInfo
{
public NativePoint Reserved;
public NativePoint MaxSize;
public NativePoint MaxPosition;
public NativePoint MinTrackSize;
public NativePoint MaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct AppBarData
{
public int Size;
public IntPtr Window;
public uint CallbackMessage;
public int Edge;
public NativeRect Rect;
public IntPtr Param;
}
[DllImport("shell32.dll")]
private static extern IntPtr SHAppBarMessage(int message, ref AppBarData data);
[StructLayout(LayoutKind.Sequential)]
private struct NativeRect
{
public int Left, Top, Right, Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int Size;
public NativeRect Monitor;
public NativeRect Work;
public uint Flags;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
}
+18
View File
@@ -116,3 +116,21 @@ public sealed class NullToVisibilityConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// Hauteur d'une page « en deux colonnes » du menu d'administration : toute la zone visible (arrondie à l'inférieur, pour
/// ne jamais faire apparaître d'ascenseur inutile), mais jamais moins que le paramètre (pixels) : sur un très petit écran,
/// la page entière défile alors, et rien n'est rogné.
/// </summary>
public sealed class ViewportHeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var viewport = value is double d && !double.IsNaN(d) && !double.IsInfinity(d) ? Math.Floor(d) : 0;
var minimum = double.TryParse(parameter as string, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) ? m : 0;
return Math.Max(viewport, minimum);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+3 -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>2.4.1</Version>
<Version>3.1.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -52,6 +52,8 @@
<!-- Modules (SDK commun + menu d'administration) : logique et ViewModels, sans WPF ; les vues sont ici (Views\Admin, Themes\Admin.xaml). -->
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
<ProjectReference Include="..\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<!-- Module « Aide » (demandes des joueurs) : formulaire sans WPF ; la fenêtre est ici (Views\FeedbackWindow.xaml). -->
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,78 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using GameLauncher.Core.Utils;
namespace GameLauncher.Platform;
/// <summary>
/// Mode d'affichage mémorisé de chaque fenêtre qui le permet (plein écran ou fenêtré) :
/// %AppData%\&lt;Publisher&gt;\GameLauncher\window-modes.json, ex. { "main": true, "admin": false }.
/// Simple préférence d'affichage, sans donnée personnelle ; fichier illisible ou modifié à la main : ignoré.
/// </summary>
public sealed partial class WindowModeStore
{
public const string FileName = "window-modes.json";
private const int MaxFileSize = 4 * 1024;
private const int MaxEntries = 32;
private readonly string _path;
private readonly Dictionary<string, bool> _modes;
private readonly object _lock = new();
public WindowModeStore(string path)
{
_path = path;
_modes = Load(path);
}
/// <summary>Plein écran mémorisé pour cette fenêtre (null : jamais choisi).</summary>
public bool? Get(string key)
{
lock (_lock) return _modes.TryGetValue(key, out var fullScreen) ? fullScreen : null;
}
public void Set(string key, bool fullScreen)
{
if (!IsValidKey(key)) return;
Dictionary<string, bool> snapshot;
lock (_lock)
{
if (_modes.TryGetValue(key, out var current) && current == fullScreen) return;
if (!_modes.ContainsKey(key) && _modes.Count >= MaxEntries) return;
_modes[key] = fullScreen;
snapshot = new Dictionary<string, bool>(_modes);
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!);
FileUtil.WriteAllTextAtomic(_path, JsonSerializer.Serialize(snapshot, JsonDefaults.Options));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Mode d'affichage non mémorisé ({_path}) : {ex.Message}");
}
}
private static Dictionary<string, bool> Load(string path)
{
var modes = new Dictionary<string, bool>(StringComparer.Ordinal);
try
{
if (!File.Exists(path) || new FileInfo(path).Length > MaxFileSize) return modes;
var loaded = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(path), JsonDefaults.Options);
foreach (var (key, value) in loaded ?? new Dictionary<string, bool>())
if (IsValidKey(key) && modes.Count < MaxEntries) modes[key] = value;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Modes d'affichage illisibles ({path}) : {ex.Message}");
}
return modes;
}
private static bool IsValidKey(string key) => KeyPattern().IsMatch(key);
[GeneratedRegex("^[a-z0-9][a-z0-9.-]{0,31}$")]
private static partial Regex KeyPattern();
}
+1 -1
View File
@@ -40,7 +40,7 @@ public sealed class WindowService : IWindowService
public void Restore()
{
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
}
@@ -61,7 +61,7 @@ public sealed class AdminWindowService : IDisposable
{
if (_window is not null)
{
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
return;
}
@@ -0,0 +1,71 @@
using System.Windows;
using GameLauncher.ApiClient;
using GameLauncher.Core.Utils;
using GameLauncher.Modules.Feedback;
using GameLauncher.Views;
namespace GameLauncher.Services;
/// <summary>
/// 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.
/// </summary>
public sealed class FeedbackWindowService
{
private readonly MainWindow _owner;
private readonly Uri? _apiUrl;
private readonly string _launcherVersion;
private LauncherApiClient? _client;
private FeedbackWindow? _window;
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion)
{
_owner = owner;
_apiUrl = apiUrl is not null && Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri) ? uri : null;
_launcherVersion = launcherVersion;
}
public bool IsAvailable => _apiUrl is not null;
/// <param name="installedVersion">Version installée du jeu (null : non installé), pour les informations techniques.</param>
public void Open(string gameId, string gameName, string? installedVersion)
{
if (_apiUrl is null) return;
if (_window is not null)
{
_window.Activate();
return;
}
try
{
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window));
var window = new FeedbackWindow(viewModel);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
_window = window;
Log.Info($"[{gameId}] Ouverture de l'aide (demande).");
_owner.SetDimmed(true);
try
{
window.ShowDialog();
}
finally
{
_owner.SetDimmed(false);
_window = null;
}
}
catch (Exception ex)
{
_window = null;
Log.Error("Ouverture de l'aide", ex);
ModernDialog.ShowMessage(_owner.IsVisible ? _owner : null, "Aide indisponible", ex.Message, DialogKind.Error);
}
}
}
@@ -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.");
}
}
+482 -105
View File
@@ -9,6 +9,7 @@
xmlns:aaud="clr-namespace:GameLauncher.Modules.Admin.Audit;assembly=GameLauncher.Modules.Admin"
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:conv="clr-namespace:GameLauncher.Converters">
<!--
@@ -216,13 +217,14 @@
<ControlTemplate TargetType="ListBoxItem">
<Grid Background="Transparent">
<Border x:Name="Bd" CornerRadius="12" Background="Transparent" Padding="12,10">
<StackPanel Orientation="Horizontal">
<Path x:Name="Icon" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Center"
<DockPanel>
<Path x:Name="Icon" DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
<TextBlock x:Name="Label" Text="{Binding Title}" Margin="12,0,0,0" VerticalAlignment="Center"
FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}"
TextTrimming="CharacterEllipsis" />
</DockPanel>
</Border>
<Border x:Name="Indicator" Width="4" Height="20" CornerRadius="2" HorizontalAlignment="Left"
Margin="-2,0,0,0" Background="{DynamicResource AccentGradientBrush}" Opacity="0" />
@@ -359,10 +361,10 @@
<StackPanel>
<!-- Erreur de chargement -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Compte connecté -->
@@ -376,12 +378,13 @@
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" FontSize="17" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding DisplayName}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<Border Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" Padding="9,2" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding DisplayName}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,12,0"
TextTrimming="CharacterEllipsis" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="9,2" VerticalAlignment="Center">
<TextBlock Text="{Binding RoleText}" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
</StackPanel>
</WrapPanel>
<TextBlock Text="{Binding KeyText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
<Button Style="{StaticResource LinkButtonStyle}" Content="Mes appareils" HorizontalAlignment="Left" Margin="0,6,0,12"
@@ -431,6 +434,27 @@
</Grid>
</Border>
<!-- Demandes des joueurs (bouton « Aide ») : nouvelles demandes par jeu -->
<Border Style="{StaticResource AdminCardStyle}" Visibility="{Binding CanReadFeedback, Converter={StaticResource BoolToVisibility}}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Style="{StaticResource AdminIconBadgeStyle}">
<Path x:Name="FeedbackIcon" Style="{StaticResource AdminIconStyle}" Data="{StaticResource Icon.MessageQuestion}" />
</Border>
<DockPanel Grid.Column="1" Margin="14,0,0,0" VerticalAlignment="Center">
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Ouvrir" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ChevronRight}" Command="{Binding OpenFeedbackCommand}" />
<StackPanel>
<TextBlock Text="Demandes des joueurs" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock x:Name="FeedbackStateText" Text="{Binding FeedbackText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
</StackPanel>
</DockPanel>
</Grid>
</Border>
<!-- Serveur et launcher -->
<Border Style="{StaticResource AdminCardStyle}">
<Grid>
@@ -492,7 +516,8 @@
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="{Binding GameId, Mode=OneWay}" Foreground="{DynamicResource TextMutedBrush}" />
</TextBlock>
<TextBlock Grid.Column="1" Text="{Binding ManifestHost}" FontSize="12" Margin="12,0,0,0"
<TextBlock Grid.Column="1" Text="{Binding ManifestHost}" FontSize="12" Margin="12,0,0,0" MaxWidth="320"
TextTrimming="CharacterEllipsis" ToolTip="{Binding ManifestHost}"
Foreground="{DynamicResource TextMutedBrush}" />
</Grid>
</DataTemplate>
@@ -507,6 +532,10 @@
<Setter TargetName="PendingIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="PendingText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding HasNewFeedback}" Value="True">
<Setter TargetName="FeedbackIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
<Setter TargetName="FeedbackStateText" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
@@ -567,21 +596,21 @@
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<!-- Erreur (chargement, publication refusée par le serveur…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Publication non configurée sur le serveur -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620"
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5"
Text="Publication désactivée : le fichier public du catalogue n'est pas configuré sur le serveur (LauncherApi:Catalog:PublicFile). Les modifications peuvent être préparées mais pas publiées." />
</StackPanel>
</DockPanel>
</Border>
<!-- Publié entre-temps par un autre administrateur -->
@@ -600,11 +629,11 @@
<!-- Résultat de la dernière publication -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
</StackPanel>
@@ -984,7 +1013,7 @@
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<TextBlock DockPanel.Dock="Left" Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="360" HorizontalAlignment="Left"
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="360" HorizontalAlignment="Stretch"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding IsGameSwitchEnabled}" ToolTip="Changer de jeu" />
</DockPanel>
@@ -998,10 +1027,10 @@
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Manifest modifié entre-temps -->
@@ -1020,11 +1049,11 @@
<!-- Résultat du dernier enregistrement -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Jeu : à choisir avant tout (plusieurs jeux), puis liste pour en changer -->
@@ -1227,21 +1256,21 @@
<StackPanel>
<!-- Erreur (contrôle du dossier, refus du serveur…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Version publiée -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Envoi interrompu : reprise ou abandon -->
@@ -1277,16 +1306,16 @@
<TextBlock Text="Dossier de publication" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<!-- Signature de la version en ligne (phase 7) -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
<DockPanel Margin="0,4,0,0"
Visibility="{Binding HasOnlineSignatureText, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" />
</DockPanel>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,6,0"
@@ -1348,11 +1377,11 @@
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<DockPanel Margin="0,4,0,0">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -1484,19 +1513,19 @@
<DataTemplate DataType="{x:Type aacc:MyDevicesPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<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}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
@@ -1600,19 +1629,19 @@
<DataTemplate DataType="{x:Type aacc:AdminsPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<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}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
@@ -1747,11 +1776,11 @@
<!-- Groupe système, ou votre propre groupe -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasNotice, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Lock}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Lock}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding Notice}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="560" />
</StackPanel>
<TextBlock Text="{Binding Notice}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<TextBlock Text="Nom du groupe" Style="{StaticResource AdminFieldLabelStyle}" />
@@ -1842,19 +1871,19 @@
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<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}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
</StackPanel>
@@ -1986,11 +2015,11 @@
<DataTemplate DataType="{x:Type aaud:AuditPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Filtres -->
@@ -2004,7 +2033,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (nom, adresse IP, clé, version…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBlock Text="Rechercher (nom, appareil, adresse IP, clé…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="100"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
@@ -2076,6 +2105,330 @@
</StackPanel>
</DataTemplate>
<!-- ============================================================ Page « Demandes des joueurs » (bouton « Aide ») -->
<!-- Demande dans la liste : type, titre, référence et date, statut ; point = nouvelle. Texte affiché tel quel (TextBlock). -->
<Style x:Key="AdminFeedbackItemStyle" 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,9" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<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}}" />
</Border>
<StackPanel Grid.Column="1" Margin="10,0,8,0">
<TextBlock x:Name="ItemTitle" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" ToolTip="{Binding Title}" />
<TextBlock Text="{Binding DetailText}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</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"
Foreground="{DynamicResource Accent2Brush}" />
</StackPanel>
<TextBlock Text="{Binding Badge}" FontSize="11" Margin="0,2,0,0" HorizontalAlignment="Right"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding HasBadge, Converter={StaticResource BoolToVisibility}}" />
</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 IsNew}" Value="True">
<Setter TargetName="NewDot" Property="Visibility" Value="Visible" />
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsClosed}" Value="True">
<Setter TargetName="StatusText" Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter TargetName="ItemTitle" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- 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}}" />
</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}"
Command="{Binding CopyReferenceCommand}" />
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="15" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0">
<Run Text="{Binding KindLabel, Mode=OneWay}" />
<Run Text="·" />
<Run Text="{Binding Reference, Mode=OneWay}" FontFamily="Cascadia Mono, Consolas, Courier New" />
<Run Text="·" />
<Run Text="{Binding ReceivedText, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</DockPanel>
<!-- Champs du formulaire : texte brut, sélectionnable pour être copié -->
<ItemsControl ItemsSource="{Binding Lines}" Focusable="False" Margin="0,16,0,0">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Margin="0,0,0,12">
<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}" />
</StackPanel>
</Border>
<!-- Traitement : statut et note interne (feedback.manage) -->
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Text="Traitement" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,3,0,12"
Text="Visible des administrateurs uniquement : le joueur n'est pas informé (demande anonyme)." />
<Grid IsEnabled="{Binding CanManage}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Statut" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Statuses}"
SelectedItem="{Binding Status, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="12,0,0,0">
<TextBlock Text="Note interne (facultative)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="70" MaxHeight="140"
MaxLength="{Binding NoteMaxLength}" ui:TextAssist.Placeholder="Ex. : reproduit sur la 0.9.3, corrigé dans la 0.9.4"
Text="{Binding Note, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="{Binding NoteLengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</Grid>
<TextBlock Text="{Binding StatusChangedText}" Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
Visibility="{Binding StatusChangedText, Converter={StaticResource StringToVisibility}}" />
<TextBlock Style="{StaticResource AdminHintStyle}" Margin="0,8,0,0"
Text="Lecture seule : le traitement demande la permission « Demandes des joueurs (traitement) »."
Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type afb:FeedbackPanelViewModel}">
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : liste à gauche, fiche à 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>
<!-- Demande modifiée par un autre administrateur -->
<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="Cette demande a été modifiée par un autre administrateur : rechargez-la avant d'enregistrer." />
</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>
<!-- Réception désactivée / plafond atteint -->
<Border Style="{StaticResource AdminInfoBannerStyle}" Visibility="{Binding ShowDisabled, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="La réception des demandes n'est pas activée sur le serveur : le bouton « Aide » des joueurs affiche « non disponible ». Voir server/deploy/LISEZMOI.md, étape 3 octies." />
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowStorageFull, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Plafond atteint (nombre de demandes, taille ou espace disque) : les nouvelles demandes sont refusées. Traitez et supprimez des demandes, ou relevez les plafonds (LauncherApi:Feedback)." />
</Border>
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGamePickerTemplate}" Focusable="False"
Visibility="{Binding ShowGamePicker, Converter={StaticResource BoolToVisibility}}" />
<ContentControl Content="{Binding}" ContentTemplate="{StaticResource AdminGameSwitcherTemplate}" Focusable="False"
Visibility="{Binding ShowGameSwitcher, Converter={StaticResource BoolToVisibility}}" />
<!-- Filtres -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14" Visibility="{Binding HasSelectedGame, Converter={StaticResource BoolToVisibility}}">
<Grid IsEnabled="{Binding IsEditable}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="190" />
<ColumnDefinition Width="250" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (titre, référence)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="100"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Type" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Kinds}"
SelectedItem="{Binding Kind, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Grid.Column="2" Margin="10,0,0,0">
<TextBlock Text="Statut" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Statuses}"
SelectedItem="{Binding Status, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
</Grid>
</Border>
<TextBlock Text="{Binding EmptyText}" Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,4,0,16"
Visibility="{Binding EmptyText, Converter={StaticResource StringToVisibility}}" />
</StackPanel>
<Grid Visibility="{Binding HasItems, Converter={StaticResource BoolToVisibility}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="330" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding CountText}" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
<Button DockPanel.Dock="Bottom" Style="{StaticResource SmallButtonStyle}" Content="Charger plus" Margin="0,4,8,0"
HorizontalAlignment="Left" ui:IconAssist.Icon="{StaticResource Icon.History}" Command="{Binding LoadMoreCommand}"
Visibility="{Binding CanLoadMore, Converter={StaticResource BoolToVisibility}}" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
<ListBox ItemsSource="{Binding Items}" SelectedItem="{Binding SelectedItem, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}" IsEnabled="{Binding IsEditable}"
ItemContainerStyle="{StaticResource AdminFeedbackItemStyle}" />
</ScrollViewer>
</DockPanel>
<!-- Fiche de la demande sélectionnée (modèle FeedbackDetailView ci-dessus) : remonte en haut à chaque changement -->
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0"
ui:ScrollAssist.ResetOn="{Binding Detail}">
<Grid>
<TextBlock Text="Choisissez une demande dans la liste." Style="{StaticResource AdminHintStyle}" FontSize="12.5" Margin="4,8,0,0"
Visibility="{Binding ShowNoSelection, Converter={StaticResource BoolToVisibility}}" />
<ContentControl Content="{Binding Detail}" Focusable="False" />
</Grid>
</ScrollViewer>
</Grid>
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Signature » (phase 7) -->
<!-- Clé du trousseau proposé -->
@@ -2142,19 +2495,19 @@
<DataTemplate DataType="{x:Type asig:SigningPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<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}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<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}}">
@@ -2175,14 +2528,14 @@
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Trousseau publié" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding KeyringText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
<DockPanel Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0" Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" />
</DockPanel>
<TextBlock FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding KeyringUrl, Converter={StaticResource StringToVisibility}}">
<Run Text="Adresse à mettre dans « keyringUrl » de launcher.json :" />
@@ -2321,21 +2674,21 @@
<StackPanel>
<!-- Erreur (refus du serveur : fichier utilisé, droits du dossier…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Résultat de la dernière suppression -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<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" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Espace disque -->
@@ -2478,6 +2831,30 @@
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.FeedbackPanelViewModel">
<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 ReloadCommand}"
ToolTip="Relire les demandes sur le serveur" />
<Button Style="{StaticResource DangerButtonStyle}" Content="Supprimer" Margin="0,0,10,0"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding DeleteCommand}"
ToolTip="Supprime définitivement la demande affichée (requête signée par votre clé)"
Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Enregistrer"
ui:IconAssist.Icon="{StaticResource Icon.ContentSaveOutline}" Command="{Binding SaveCommand}"
ToolTip="Enregistre le statut et la note interne (requête signée par votre clé)"
Visibility="{Binding CanManage, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<TextBlock Text="{Binding RetentionText}" Style="{StaticResource AdminHintStyle}" VerticalAlignment="Center" TextWrapping="Wrap" />
</DockPanel>
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Height="3" IsIndeterminate="{Binding IsBusy}"
Visibility="{Binding IsBusy, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="AdminFooter.PublishPanelViewModel">
<StackPanel>
<Border Style="{StaticResource AdminFooterSeparatorStyle}" />
+21
View File
@@ -12,6 +12,7 @@
<conv:StringToVisibilityConverter x:Key="StringToVisibility" />
<conv:IconKeyConverter x:Key="IconKey" />
<conv:KindToResourceConverter x:Key="KindToResource" />
<conv:ViewportHeightConverter x:Key="ViewportHeight" />
<!-- ============================================================ Focus clavier discret -->
<Style x:Key="FocusVisualStyle">
@@ -69,6 +70,26 @@
<Setter Property="ui:IconAssist.HoverBrush" Value="{DynamicResource DangerBrush}" />
</Style>
<!--
Plein écran / mode fenêtré (F11), pour toute fenêtre inscrite avec ui:WindowModeAssist.AllowFullScreen="True" :
icône et infobulle suivent le mode ; bouton masqué dans une fenêtre qui ne le permet pas.
Usage : <Button Style="{StaticResource WindowModeButtonStyle}" />, entre « Réduire » et « Fermer ».
-->
<Style x:Key="WindowModeButtonStyle" TargetType="Button" BasedOn="{StaticResource IconButtonStyle}">
<Setter Property="Command" Value="{x:Static ui:WindowModeAssist.ToggleFullScreenCommand}" />
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.Fullscreen}" />
<Setter Property="ToolTip" Value="Plein écran (F11)" />
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.FullscreenExit}" />
<Setter Property="ToolTip" Value="Quitter le plein écran (F11)" />
</DataTrigger>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.AllowFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ============================================================ Bouton principal (JOUER / INSTALLER…) -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Height" Value="60" />
+8
View File
@@ -17,6 +17,9 @@
<Geometry x:Key="Icon.Cog">M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z</Geometry>
<Geometry x:Key="Icon.Close">M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z</Geometry>
<Geometry x:Key="Icon.Minimize">M20,14H4V10H20</Geometry>
<!-- Plein écran (F11) / quitter le plein écran : bouton des fenêtres qui le permettent (WindowModeAssist) -->
<Geometry x:Key="Icon.Fullscreen">M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z</Geometry>
<Geometry x:Key="Icon.FullscreenExit">M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z</Geometry>
<Geometry x:Key="Icon.WindowRestore">M4,8H8V4H20V16H16V20H4V8M16,8V14H18V6H10V8H16M6,12V18H14V12H6Z</Geometry>
<Geometry x:Key="Icon.ExitToApp">M19,3H5C3.89,3 3,3.89 3,5V9H5V5H19V19H5V15H3V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10.08,15.58L11.5,17L16.5,12L11.5,7L10.08,8.41L12.67,11H3V13H12.67L10.08,15.58Z</Geometry>
<Geometry x:Key="Icon.Folder">M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z</Geometry>
@@ -94,5 +97,10 @@
<Geometry x:Key="Icon.AccountRemove">M15,14C17.67,14 23,15.33 23,18V20H7V18C7,15.33 12.33,14 15,14M15,12A4,4 0 0,1 11,8A4,4 0 0,1 15,4A4,4 0 0,1 19,8A4,4 0 0,1 15,12M5,9.59L7.12,7.46L8.54,8.88L6.41,11L8.54,13.12L7.12,14.54L5,12.41L2.88,14.54L1.46,13.12L3.59,11L1.46,8.88L2.88,7.46L5,9.59Z</Geometry>
<Geometry x:Key="Icon.Broom">M19.36,2.72L20.78,4.14L15.06,9.85C16.13,11.39 16.28,13.24 15.38,14.44L9.06,8.12C10.26,7.22 12.11,7.37 13.65,8.44L19.36,2.72M5.93,17.57C3.92,15.56 2.69,13.16 2.35,10.92L7.23,8.83L14.67,16.27L12.58,21.15C10.34,20.81 7.94,19.58 5.93,17.57Z</Geometry>
<Geometry x:Key="Icon.CalendarRange">M9,10H7V12H9V10M13,10H11V12H13V10M17,10H15V12H17V10M19,3H18V1H16V3H8V1H6V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3M19,19H5V8H19V19Z</Geometry>
<!-- Demandes des joueurs (bouton « Aide ») : types Avis, Demande d'évolution, Bug -->
<Geometry x:Key="Icon.Star">M12,17.27L18.18,21L16.54,13.97L22,9.24L14.81,8.62L12,2L9.19,8.62L2,9.24L7.45,13.97L5.82,21L12,17.27Z</Geometry>
<Geometry x:Key="Icon.Lightbulb">M12,2A7,7 0 0,1 19,9C19,11.38 17.81,13.47 16,14.74V17A1,1 0 0,1 15,18H9A1,1 0 0,1 8,17V14.74C6.19,13.47 5,11.38 5,9A7,7 0 0,1 12,2M9,21V20H15V21A1,1 0 0,1 14,22H10A1,1 0 0,1 9,21M12,4A5,5 0 0,0 7,9C7,11.05 8.23,12.81 10,13.58V16H14V13.58C15.77,12.81 17,11.05 17,9A5,5 0 0,0 12,4Z</Geometry>
<Geometry x:Key="Icon.Bug">M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z</Geometry>
<Geometry x:Key="Icon.MessageQuestion">M4,2H20A2,2 0 0,1 22,4V16A2,2 0 0,1 20,18H16L12,22L8,18H4A2,2 0 0,1 2,16V4A2,2 0 0,1 4,2M12.19,5.5C11.3,5.5 10.59,5.68 10.05,6.04C9.5,6.4 9.22,7 9.27,7.69H11.24C11.24,7.41 11.34,7.2 11.5,7.06C11.7,6.92 11.92,6.85 12.19,6.85C12.5,6.85 12.77,6.93 12.95,7.11C13.13,7.28 13.22,7.5 13.22,7.8C13.22,8.08 13.14,8.33 13,8.54C12.83,8.76 12.62,8.94 12.36,9.08C11.84,9.4 11.5,9.68 11.29,9.92C11.1,10.16 11,10.5 11,11H13C13,10.72 13.05,10.5 13.14,10.32C13.23,10.15 13.4,10 13.66,9.85C14.12,9.64 14.5,9.36 14.79,9C15.08,8.63 15.23,8.24 15.23,7.8C15.23,7.1 14.96,6.54 14.42,6.12C13.88,5.71 13.13,5.5 12.19,5.5M11,12V14H13V12H11Z</Geometry>
</ResourceDictionary>
@@ -65,6 +65,9 @@ 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>
public bool IsHelpAvailable { get; set; }
public RelayCommand MinimizeCommand { get; }
public RelayCommand CloseCommand { get; }
+57 -16
View File
@@ -17,7 +17,9 @@
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True">
ui:WheelAssist.IsEnabled="True"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="admin">
<!--
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
@@ -27,6 +29,29 @@
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
-->
<Window.Resources>
<!--
Mode fenêtré : ombre et coins arrondis. En plein écran (F11, bouton de la barre de titre), WindowModeAssist met ces
ressources « WindowChrome.* » à 0. Toute page d'un module (présente ou future) s'affiche dans cette fenêtre et
profite donc du plein écran sans rien déclarer.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">20</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">20</CornerRadius>
<CornerRadius x:Key="WindowChrome.NavRadius">0,0,0,20</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
<local:LoginView />
</DataTemplate>
@@ -44,7 +69,7 @@
</Grid.ColumnDefinitions>
<!-- Navigation -->
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
<Border Background="{DynamicResource RailBrush}" CornerRadius="{DynamicResource WindowChrome.NavRadius}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
<DockPanel Margin="16,20,16,18">
<!-- Compte connecté -->
@@ -93,7 +118,7 @@
<!-- Page affichée -->
<DockPanel Grid.Column="1" Margin="32,22,18,18">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
@@ -103,41 +128,54 @@
Content="{Binding SelectedPanel.ViewModel}"
ContentTemplateSelector="{StaticResource AdminFooterSelector}" />
<!-- Retour en haut : page changée, ou demandé par la page (après « Recharger », « Publier »…).
Page en deux colonnes (HasSplitLayout) : la fenêtre ne fait plus défiler la page entière ; la page reçoit
la hauteur disponible et chaque colonne a son propre ascenseur (Themes/Admin.xaml). -->
<ScrollViewer Focusable="False"
Page en deux colonnes (HasSplitLayout) : la page reçoit la hauteur visible et chaque colonne a son propre
ascenseur (Themes/Admin.xaml) ; sur un très petit écran (moins de 360 px de haut), la page entière défile
en plus, pour que rien ne soit jamais rogné (bandeaux en haut compris). -->
<ScrollViewer Focusable="False" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
<ScrollViewer.Style>
<Style TargetType="ScrollViewer">
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="Padding" Value="0,0,14,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="Padding" Value="0" />
</DataTrigger>
</Style.Triggers>
</Style>
</ScrollViewer.Style>
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False">
<ContentControl.Style>
<Style TargetType="ContentControl">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="Height"
Value="{Binding ViewportHeight, RelativeSource={RelativeSource AncestorType=ScrollViewer}, Converter={StaticResource ViewportHeight}, ConverterParameter=360}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ContentControl.Style>
</ContentControl>
</ScrollViewer>
</DockPanel>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Margin="20">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<!-- Ombre portée -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource SurfaceSolidBrush}"
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
<!--
Responsive (ScaleToFitDecorator) : 1038 × 678 en mode fenêtré. Plus grand (plein écran) : les pages s'étalent.
Plus petit : jusqu'à 960 × 560 la mise en page s'adapte (les pages défilent) ; en dessous (petit écran, mise à
l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator DesignWidth="960" DesignHeight="560">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="64" />
@@ -172,6 +210,8 @@
</Border>
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
ToolTip="Réduire" Click="OnMinimize" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="9" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
</StackPanel>
@@ -181,6 +221,7 @@
<!-- Écran courant -->
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
</Grid>
</ui:ScaleToFitDecorator>
</Border>
</Grid>
</Window>
@@ -29,7 +29,7 @@ public partial class AdminWindow : Window
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
PreviewKeyDown += OnPreviewKeyDown;
Loaded += (_, _) => FitToScreen();
// Taille adaptée à l'écran (mode fenêtré) et plein écran : ui:WindowModeAssist (AdminWindow.xaml).
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
SystemEvents.SessionSwitch += OnSessionSwitch;
@@ -54,16 +54,6 @@ public partial class AdminWindow : Window
_shell.NotifyActivity();
}
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
private void FitToScreen()
{
var work = SystemParameters.WorkArea;
if (Height > work.Height) Height = work.Height;
if (Width > work.Width) Width = work.Width;
if (Top < work.Top) Top = work.Top;
if (Left < work.Left) Left = work.Left;
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
_shell.NotifyActivity();
@@ -87,10 +77,11 @@ public partial class AdminWindow : Window
});
}
/// <summary>Déplacement par la barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
@@ -13,14 +13,17 @@ public partial class ChangelogWindow : Window
InitializeComponent();
SubtitleText.Text = string.IsNullOrWhiteSpace(gameName) ? "Nouveautés et correctifs" : $"{gameName} · nouveautés et correctifs";
Entries.ItemsSource = entries;
FitToScreen();
SourceInitialized += (_, _) => FitToScreen();
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran : au-delà, les notes défilent.</summary>
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (celle-ci peut être en plein écran sur
/// un autre écran que l'écran principal) : au-delà, les notes défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 250; // en-tête, bouton, marges et ombre
EntriesScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
EntriesScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
+378
View File
@@ -0,0 +1,378 @@
<Window x:Class="GameLauncher.Views.FeedbackWindow"
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"
xmlns:conv="clr-namespace:GameLauncher.Converters"
Title="Aide"
Width="680"
SizeToContent="Height"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ResizeMode="NoResize"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
FontFamily="{DynamicResource UiFont}"
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" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" CornerRadius="16" Padding="16,14" BorderThickness="1" SnapsToDevicePixels="True"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Bd" Property="Opacity" Value="0.8" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</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>
</Window.Resources>
<Grid Margin="20">
<!-- Ombre -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border Style="{StaticResource DialogCardStyle}" Padding="28,26,14,22" MouseLeftButtonDown="OnDragMove">
<StackPanel>
<!-- En-tête : type de demande et jeu concerné (figé à l'ouverture) -->
<Grid Margin="0,0,14,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<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" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding FormTitle}" FontSize="19" FontWeight="SemiBold" />
<TextBlock Text="{Binding GameText}" FontSize="12.5" Margin="0,3,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<Button Grid.Column="2" Style="{StaticResource IconButtonStyle}" VerticalAlignment="Top"
Width="32" Height="32" Padding="9" Margin="0,-6,-10,0" ToolTip="Fermer"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding CloseCommand}" />
</Grid>
<ScrollViewer x:Name="BodyScroll" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
Focusable="False" Padding="0,0,14,0">
<StackPanel>
<!-- ============================================ É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" />
<ItemsControl ItemsSource="{Binding Kinds}" Focusable="False" Margin="0,0,-12,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="2" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Style="{StaticResource FeedbackKindTileStyle}" Command="{Binding SelectCommand}">
<DockPanel>
<Path DockPanel.Dock="Right" Width="10" Height="10" Stretch="Uniform" Margin="10,0,0,0"
VerticalAlignment="Center" Fill="{DynamicResource TextMutedBrush}"
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}}" />
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding Description}" FontSize="12" Margin="0,3,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</DockPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
<!-- ============================================ Étape 2 : formulaire -->
<StackPanel Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}">
<!-- Erreur (contrôle local ou refus du serveur) : la saisie est conservée -->
<Border CornerRadius="12" Padding="14,10" Margin="0,0,0,14" Background="#26E5484D"
BorderBrush="{DynamicResource DangerBrush}" BorderThickness="1"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" VerticalAlignment="Top"
Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<StackPanel IsEnabled="{Binding IsEditable}">
<!-- Titre -->
<StackPanel Margin="0,0,0,14">
<TextBlock Text="Titre *" Style="{StaticResource FeedbackLabelStyle}" />
<TextBox x:Name="TitleBox" Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding TitleMaxLength}"
SpellCheck.IsEnabled="True" ui:TextAssist.Placeholder="{Binding TitleHint}"
Text="{Binding Title, UpdateSourceTrigger=PropertyChanged}" />
<DockPanel Margin="0,4,4,0">
<TextBlock DockPanel.Dock="Right" Text="{Binding TitleLengthText}" FontSize="11"
Foreground="{DynamicResource TextMutedBrush}" />
<TextBlock Text="{Binding TitleError}" Style="{StaticResource FeedbackFieldErrorStyle}" Margin="2,0,8,0"
Visibility="{Binding HasTitleError, Converter={StaticResource BoolToVisibility}}" />
</DockPanel>
</StackPanel>
<!-- 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 ScreenshotsAvailable, 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}">
<StackPanel>
<CheckBox Style="{StaticResource ToggleSwitchStyle}" Margin="0,8" IsChecked="{Binding IncludeTech}">
<StackPanel>
<TextBlock Text="Joindre les informations techniques" FontSize="13" FontWeight="SemiBold" />
<TextBlock Text="Aide à reproduire un bug. Rien d'autre que ce qui est affiché ci-dessous n'est envoyé."
FontSize="11.5" Margin="0,2,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</CheckBox>
<TextBlock Text="{Binding TechPreview}" FontSize="11.5" Margin="2,2,0,0" TextWrapping="Wrap"
FontFamily="Cascadia Mono, Consolas, Courier New"
Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding IncludeTech, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</Border>
</StackPanel>
<DockPanel Margin="0,0,0,4">
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Top" Margin="0,1,0,0"
Fill="{DynamicResource TextMutedBrush}" Data="{StaticResource Icon.ShieldCheck}" />
<TextBlock Text="{Binding PrivacyText}" FontSize="11.5" Margin="8,0,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextMutedBrush}" />
</DockPanel>
</StackPanel>
<!-- ============================================ Étape 3 : envoyée -->
<StackPanel Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}" Margin="0,6,0,6">
<Path Width="44" Height="44" Stretch="Uniform" HorizontalAlignment="Center" Fill="{DynamicResource SuccessBrush}"
Data="{StaticResource Icon.CheckCircle}" />
<TextBlock Text="Merci, votre demande a bien été envoyée." FontSize="16" FontWeight="SemiBold" Margin="0,12,0,0"
HorizontalAlignment="Center" />
<TextBlock Text="Conservez sa référence si vous souhaitez en parler avec l'équipe (Discord, e-mail…)."
FontSize="12.5" Margin="0,6,0,14" HorizontalAlignment="Center" TextWrapping="Wrap" TextAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
<Border HorizontalAlignment="Center" CornerRadius="14" Padding="18,10,10,10" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderStrongBrush}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding Reference}" FontSize="22" FontWeight="SemiBold" VerticalAlignment="Center"
FontFamily="Cascadia Mono, Consolas, Courier New" />
<Button Style="{StaticResource IconButtonStyle}" Width="34" Height="34" Padding="9" Margin="12,0,0,0"
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}" Click="OnCopyReference" />
</StackPanel>
</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>
<!-- Boutons -->
<DockPanel Margin="0,16,14,0">
<Button DockPanel.Dock="Left" Style="{StaticResource LinkButtonStyle}" Content="Changer de type" FontSize="12.5"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.ArrowLeft}" Command="{Binding BackCommand}"
Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" Margin="0,0,14,0"
VerticalAlignment="Center" IsIndeterminate="{Binding IsSending}"
Visibility="{Binding IsSending, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Annuler" Margin="0,0,10,0"
Command="{Binding CloseCommand}"
Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Envoyer" IsDefault="False"
ui:IconAssist.Icon="{StaticResource Icon.ArrowRight}" Command="{Binding SendCommand}"
Visibility="{Binding IsFilling, Converter={StaticResource BoolToVisibility}}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Fermer" IsDefault="True"
ui:IconAssist.Icon="{StaticResource Icon.Check}" Command="{Binding CloseCommand}"
Visibility="{Binding IsDone, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DockPanel>
</StackPanel>
</Border>
</Grid>
</Window>
@@ -0,0 +1,117 @@
using System.Windows;
using System.Windows.Input;
using GameLauncher.Modules.Feedback;
namespace GameLauncher.Views;
/// <summary>
/// Bouton « Aide » : avis, demande d'évolution, bug ou autre, pour le jeu sélectionné (voir <see cref="FeedbackFormViewModel"/>).
/// Fermer la fenêtre abandonne un envoi en cours.
/// </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)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = viewModel;
viewModel.CloseRequested += Close;
viewModel.PropertyChanged += (_, e) =>
{
// 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;
viewModel.Dispose();
};
}
/// <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
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)
{
if (string.IsNullOrEmpty(_viewModel.Reference)) return;
try
{
Clipboard.SetText(_viewModel.Reference);
CopiedText.Visibility = Visibility.Visible;
}
catch (System.Runtime.InteropServices.ExternalException)
{
// presse-papiers occupé par une autre application : la référence reste affichée
}
}
/// <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;
}
private void OnDragMove(object sender, MouseButtonEventArgs e)
{
if (e.ButtonState == MouseButtonState.Pressed && e.OriginalSource is not System.Windows.Controls.TextBox) DragMove();
}
}
+56 -18
View File
@@ -14,9 +14,34 @@
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
TextOptions.TextFormattingMode="Ideal"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="main">
<Window.Resources>
<!--
Mode fenêtré : ombre autour de la fenêtre et coins arrondis. En plein écran (F11, bouton de la barre de titre),
WindowModeAssist met ces ressources « WindowChrome.* » à 0 : la fenêtre occupe toute la zone utile de l'écran
(barre des tâches visible), sans marge ni arrondi.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">16</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">22</CornerRadius>
<CornerRadius x:Key="WindowChrome.RailRadius">22,0,0,22</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ChipStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
@@ -166,24 +191,21 @@
</Style>
</Window.Resources>
<Grid Margin="16">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<Grid x:Name="Root">
<!-- ===================================================== Fond (thème) : ombre, image, lueur, dégradés -->
<Border CornerRadius="22" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<!-- Illustration du thème (Default : Assets/background.png, Exovival : Assets/Themes/exovival-background.jpg) -->
<Border x:Name="BackgroundLayer" CornerRadius="22">
<Border x:Name="BackgroundLayer" CornerRadius="{DynamicResource WindowChrome.Radius}">
<Border.Background>
<ImageBrush ImageSource="{DynamicResource BackgroundImage}" Stretch="UniformToFill" AlignmentX="Right" />
</Border.Background>
</Border>
<Border CornerRadius="22" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="22" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="22" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="22" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Grid>
<Grid.ColumnDefinitions>
@@ -192,7 +214,7 @@
</Grid.ColumnDefinitions>
<!-- ===================================================== Liste des jeux (si plusieurs jeux) -->
<Border Width="88" CornerRadius="22,0,0,22" Background="{DynamicResource RailBrush}"
<Border Width="88" CornerRadius="{DynamicResource WindowChrome.RailRadius}" Background="{DynamicResource RailBrush}"
MouseLeftButtonDown="OnTitleBarMouseDown"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"
Visibility="{Binding IsMultiGame, Converter={StaticResource BoolToVisibility}}">
@@ -208,7 +230,13 @@
</DockPanel>
</Border>
<Grid Grid.Column="1" DataContext="{Binding SelectedGame}">
<!--
Responsive (ScaleToFitDecorator) : zone du jeu de 1168 × 708 en mode fenêtré. Plus grand (plein écran) : le contenu
s'étale (colonnes fluides). Plus petit : jusqu'à 1100 × 620 la mise en page s'adapte (la présentation et les notes
défilent) ; en dessous (petit écran, mise à l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator Grid.Column="1" DesignWidth="1100" DesignHeight="620">
<Grid DataContext="{Binding SelectedGame}">
<Grid.RowDefinitions>
<RowDefinition Height="76" />
<RowDefinition Height="*" />
@@ -243,6 +271,10 @@
</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) -->
<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}}" />
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Site officiel"
ui:IconAssist.Icon="{StaticResource Icon.Web}"
Command="{Binding OpenWebsiteCommand}"
@@ -255,6 +287,8 @@
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Réduire" Padding="12"
ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
Command="{Binding DataContext.MinimizeCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="11" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="{Binding DataContext.CloseToolTip, RelativeSource={RelativeSource AncestorType=Window}}" Padding="12" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}"
Command="{Binding DataContext.CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
@@ -265,11 +299,13 @@
<Grid Grid.Row="1" Margin="58,6,34,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="370" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
<ColumnDefinition Width="0.5*" MinWidth="370" MaxWidth="560" />
</Grid.ColumnDefinitions>
<!-- Présentation du jeu -->
<StackPanel VerticalAlignment="Center" Margin="0,0,40,0">
<!-- Présentation du jeu : défile si elle ne tient pas en hauteur (description, liens et bandeau à la fois) -->
<ScrollViewer Margin="0,0,24,0" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<StackPanel VerticalAlignment="Center" Margin="0,0,16,0">
<!-- Badge d'état -->
<Border HorizontalAlignment="Left" CornerRadius="14" Padding="12,6"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -404,6 +440,7 @@
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Notes de version -->
<Border Grid.Column="1" Margin="0,4" Padding="22,20,10,18" CornerRadius="20"
@@ -537,6 +574,7 @@
</Grid>
</Border>
</Grid>
</ui:ScaleToFitDecorator>
</Grid>
</Grid>
@@ -544,6 +582,6 @@
<Image x:Name="TransitionImage" IsHitTestVisible="False" Visibility="Collapsed" Stretch="Fill" />
<!-- Voile sombre affiché derrière les boîtes de dialogue -->
<Border x:Name="DimOverlay" CornerRadius="22" Background="#A6000000" Visibility="Collapsed" />
<Border x:Name="DimOverlay" CornerRadius="{DynamicResource WindowChrome.Radius}" Background="#A6000000" Visibility="Collapsed" />
</Grid>
</Window>
+10 -2
View File
@@ -34,6 +34,14 @@ public partial class MainWindow : Window, IThemeTransition
/// </summary>
public event Action? AdminRequested;
/// <summary>Bouton « Aide » : demande (avis, évolution, bug…) pour le jeu sélectionné.</summary>
public event Action<GameViewModel>? HelpRequested;
private void OnHelpClick(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement { DataContext: GameViewModel game }) HelpRequested?.Invoke(game);
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
// Avec Alt, WPF signale la touche dans SystemKey (Key.System).
@@ -54,11 +62,11 @@ public partial class MainWindow : Window, IThemeTransition
AdminRequested?.Invoke();
}
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
/// <summary>Ouvre le menu "…" au-dessus du bouton.</summary>
+6 -2
View File
@@ -33,8 +33,12 @@
FontSize="19" FontWeight="SemiBold" TextWrapping="Wrap" />
</StackPanel>
<TextBlock x:Name="MessageText" Margin="0,18,0,0" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
<!-- Message long (détail d'une erreur…) : il défile, la fenêtre ne dépasse jamais l'écran -->
<ScrollViewer x:Name="MessageScroll" Margin="0,18,0,0" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Focusable="False">
<TextBlock x:Name="MessageText" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
</ScrollViewer>
<Border x:Name="OptionPanel" Margin="0,20,0,0" Padding="16,12" CornerRadius="14"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
@@ -12,6 +12,12 @@ public partial class ModernDialog : Window
private ModernDialog()
{
InitializeComponent();
// Jamais plus haute que la zone utile de l'écran de sa fenêtre parente (au-delà, le message défile).
SourceInitialized += (_, _) =>
{
const double chrome = 280; // titre, option, boutons, marges et ombre
MessageScroll.MaxHeight = Math.Max(120, Controls.WindowModeAssist.WorkArea(Owner ?? (Visual)this).Height - chrome);
};
}
public static void ShowMessage(Window? owner, string title, string message, DialogKind kind)
@@ -10,17 +10,17 @@ public partial class SettingsWindow : Window
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
Loaded += (_, _) => FitToScreen();
SourceInitialized += (_, _) => FitToScreen(); // avant le centrage sur la fenêtre parente
}
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran (mise à l'échelle 125-150 %) :
/// au-delà, les sections défilent.
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (mise à l'échelle 125-150 %, autre
/// écran) : au-delà, les sections défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 260; // en-tête, boutons, marges et ombre
SectionsScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
SectionsScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
@@ -187,7 +187,12 @@ public class AuditPanelTests
ActorName = "Paul,=1+1",
Details = "nom ; avec \"guillemets\"\nsur deux lignes"
};
var line = AuditTexts.ToCsv(new[] { new AuditItem(entry) }).Split(Environment.NewLine)[1];
var csv = AuditTexts.ToCsv(new[] { new AuditItem(entry), new AuditItem(entry with { KeyLabel = "PC bureau" }) }).Split(Environment.NewLine);
Assert.Equal("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails", csv[0]);
Assert.Contains(";\"PC bureau\";\"'@SUM(A1)\";", csv[2]);
Assert.Equal("Appareil « PC bureau »", new AuditItem(entry with { KeyLabel = "PC bureau" }).DeviceText);
Assert.StartsWith("Appareil « PC bureau » · IP", new AuditItem(entry with { KeyLabel = "PC bureau" }).OriginText);
var line = csv[1];
Assert.Contains(";\"'@SUM(A1)\";", line);
Assert.Contains(";\"'=HYPERLINK(\"\"http://x\"\")\";", line);
Assert.Contains(";\"' -2+3\";", line);
@@ -0,0 +1,263 @@
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Modules.Feedback;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Server;
namespace GameLauncher.Tests.Admin;
/// <summary>Bouton « Aide » (formulaire du joueur) et page « Demandes des joueurs » du menu d'administration.</summary>
public class FeedbackPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new FeedbackAdminModule() });
private static Task<TestApi> StartApiAsync() => TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Feedback:Enabled"] = "true",
["LauncherApi:Feedback:Games:0"] = "EZR",
["LauncherApi:Feedback:Games:1"] = "ZR2",
["LauncherApi:Feedback:ProofDifficulty"] = "8",
["LauncherApi:Feedback:RequestsPerMinute"] = "10000",
["LauncherApi:Feedback:PerIpPer10Minutes"] = "1000",
["LauncherApi:Feedback:PerIpPerDay"] = "10000"
});
private static FeedbackFormViewModel Form(LauncherApiClient client, string gameId = "EZR") =>
new(new FeedbackTarget(gameId, "Exovival Zombie Reborn", "0.9.3", "2.6.0"), new ApiFeedbackSender(client),
new FeedbackTechInfo { LauncherVersion = "2.6.0", GameVersion = "0.9.3", Os = "Windows 11 64 bits (10.0.26100)", Language = "fr-FR" });
// ------------------------------------------------------------------ Joueur
[Fact]
public async Task Player_form_checks_locally_then_sends_and_shows_the_reference()
{
await using var api = await StartApiAsync();
using var client = api.CreateClient();
using var form = Form(client);
Assert.True(form.IsChoosingKind);
Assert.Equal(4, form.Kinds.Count);
Assert.Contains("Exovival Zombie Reborn", form.GameText);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Bug).SelectCommand.Execute(null);
Assert.True(form.IsFilling);
Assert.True(form.IncludeTech); // coché par défaut pour un bug
Assert.Contains("Version du jeu : 0.9.3", form.TechPreview);
// Rien de saisi : erreurs locales, rien n'est envoyé.
await form.SendCommand.ExecuteAsync();
Assert.True(form.HasError);
Assert.True(form.HasTitleError);
Assert.Contains(form.Fields, f => f.HasError);
Assert.Empty(Directory.Exists(Path.Combine(api.DataDirectory, "feedback"))
? Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback"), "*.json", SearchOption.AllDirectories)
: Array.Empty<string>());
form.Title = "Le jeu plante à l'ouverture de l'inventaire";
Field(form, "category").SelectedOption = Field(form, "category").Options.First(o => o.Id == "crash");
Field(form, "severity").SelectedOption = Field(form, "severity").Options.First(o => o.Id == "crash");
Field(form, "frequency").SelectedOption = Field(form, "frequency").Options.First(o => o.Id == "always");
Field(form, "steps").Text = "1. Lancer une partie\n2. Touche I";
Field(form, "actual").Text = "Fermeture du jeu.";
Assert.DoesNotContain(FeedbackFieldViewModel.NoChoice, Field(form, "category").Options); // obligatoire : pas de « Non précisé »
await form.SendCommand.ExecuteAsync();
Assert.False(form.HasError, form.ErrorMessage);
Assert.True(form.IsDone);
Assert.True(FeedbackRules.IsValidReference(form.Reference));
}
[Fact]
public async Task Player_form_keeps_the_input_when_sending_fails()
{
await using var api = await TestApi.StartAsync(); // réception non activée sur le serveur
using var client = api.CreateClient();
using var form = Form(client);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Other).SelectCommand.Execute(null);
form.Title = "Une petite question";
Field(form, "message").Text = "Le jeu sortira-t-il sur Linux ?";
await form.SendCommand.ExecuteAsync();
Assert.True(form.IsFilling);
Assert.Contains("pas disponible", form.ErrorMessage);
Assert.Equal("Une petite question", form.Title);
Assert.Equal("Le jeu sortira-t-il sur Linux ?", Field(form, "message").Text);
// Retour au choix du type puis même type : la saisie est toujours là.
form.BackCommand.Execute(null);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Other).SelectCommand.Execute(null);
Assert.Equal("Une petite question", form.Title);
}
[Fact]
public void Player_rating_and_optional_choices()
{
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "EZR", null, "2.6.0"), new NeverSender());
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Review).SelectCommand.Execute(null);
Assert.False(form.IncludeTech);
Assert.Contains("non installé", form.TechPreview);
var rating = Field(form, "rating");
rating.Stars[3].SelectCommand.Execute(null);
Assert.Equal(4, rating.Rating);
Assert.Equal(new[] { true, true, true, true, false }, rating.Stars.Select(s => s.IsOn).ToArray());
Assert.Equal("4", rating.Value);
var recommend = Field(form, "recommend");
Assert.Same(FeedbackFieldViewModel.NoChoice, recommend.SelectedOption); // facultatif : « Non précisé »
Assert.Null(recommend.Value);
}
private static FeedbackFieldViewModel Field(FeedbackFormViewModel form, string id) => form.Fields.Single(f => f.Id == id);
private sealed class NeverSender : IFeedbackSender
{
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", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
// ------------------------------------------------------------------ Administrateurs
[Fact]
public async Task Admin_page_waits_for_the_game_then_lists_and_processes_requests()
{
await using var api = await StartApiAsync();
using (var client = api.CreateClient())
{
await client.SendFeedbackAsync(FeedbackModuleTests.Bug(title: "Bug de ZR2", gameId: "ZR2"));
await client.SendFeedbackAsync(FeedbackModuleTests.Bug(title: "Bug d'EZR"));
}
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
var dialogs = new FakeDialogs();
var games = new[] { AdminTestKit.Game(), AdminTestKit.Game("ZR2", "Zombie Reborn 2") };
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, Modules, games: games));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
// Tableau de bord : nouvelles demandes par jeu.
var dashboard = Assert.IsType<DashboardPanelViewModel>(workspace.SelectedPanel!.ViewModel);
await dashboard.RefreshAsync();
Assert.True(dashboard.CanReadFeedback);
Assert.True(dashboard.HasNewFeedback);
Assert.Contains("Zombie Reborn 2 : 1 nouvelle", dashboard.FeedbackText);
workspace.Navigate(FeedbackPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<FeedbackPanelViewModel>(workspace.SelectedPanel!.ViewModel);
Assert.False(page.HasError, page.ErrorMessage);
Assert.True(page.ShowGamePicker);
Assert.Empty(page.Items); // rien de chargé avant le choix du jeu
Assert.Equal("1 nouvelle · 1 à traiter · 1 au total", page.GameTiles[1].Detail);
page.GameTiles[1].SelectCommand.Execute(null);
await page.GameLoad;
Assert.Equal("Bug de ZR2", Assert.Single(page.Items).Title); // seulement les demandes de ce jeu
page.SelectedItem = page.Items[0];
await page.DetailLoad;
var detail = Assert.IsType<FeedbackDetailView>(page.Detail);
Assert.Contains(detail.Lines, l => l.Label == "Gravité" && l.Value == "Plantage");
Assert.Contains("Langue : fr-FR", detail.TechText);
detail.CopyReferenceCommand.Execute(null);
Assert.Equal(detail.Reference, dialogs.Clipboard);
// Traitement : statut et note interne.
Assert.False(page.SaveCommand.CanExecute(null));
detail.Status = detail.Statuses.Single(s => s.Value == FeedbackStatuses.Done);
detail.Note = "Corrigé dans la 0.9.4";
Assert.True(page.SaveCommand.CanExecute(null));
await page.SaveCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(FeedbackStatuses.Done, page.Detail!.Status.Value);
Assert.Equal("Traitée", page.Items[0].StatusLabel);
Assert.Equal("0 nouvelle · 0 à traiter · 1 au total", page.GameTiles[1].Detail);
// Filtre par défaut « à traiter » : la demande traitée disparaît au rechargement.
await page.ReloadCommand.ExecuteAsync();
Assert.Empty(page.Items);
page.Status = page.Statuses.Single(s => s.Label == "Toutes");
await page.GameLoad;
Assert.Single(page.Items);
// Suppression (confirmée).
page.SelectedItem = page.Items[0];
await page.DetailLoad;
await page.DeleteCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Empty(page.Items);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith("Supprimer la demande"));
}
}
@@ -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,106 @@
using GameLauncher.Contracts.Feedback;
namespace GameLauncher.Tests.Contracts;
/// <summary>Règles partagées des demandes des joueurs (launcher et serveur).</summary>
public class FeedbackRulesTests
{
[Fact]
public void Forms_have_unique_ids_and_each_required_choice_has_options()
{
Assert.Equal(FeedbackForms.All.Count, FeedbackForms.All.Select(f => f.Kind).Distinct().Count());
foreach (var form in FeedbackForms.All)
{
Assert.Equal(form.Fields.Count, form.Fields.Select(f => f.Id).Distinct().Count());
Assert.All(form.Fields.Where(f => f.Kind == FeedbackFieldKind.Choice), f => Assert.NotEmpty(f.Choices));
Assert.All(form.Fields, f => Assert.True(f.MaxLength <= FeedbackRules.MaxMultilineLength));
}
}
[Fact]
public void Multiline_text_is_normalized()
{
var text = FeedbackRules.CleanMultiline(" Ligne 1\t \r\nLigne 2\r\r\n\n\n\n\nLigne 3 \n", 2000, out var problem);
Assert.Null(problem);
Assert.Equal("Ligne 1\nLigne 2\n\n\nLigne 3", text);
Assert.Null(FeedbackRules.CleanMultiline(" \r\n\t ", 2000, out problem));
Assert.Null(problem);
Assert.Equal("Super jeu 👍", FeedbackRules.CleanMultiline("Super jeu 👍", 2000, out _)); // émoji : autorisé
}
[Theory]
[InlineData("a\u202Eb")] // inversion du sens d'écriture
[InlineData("a\u200Bb")] // espace de largeur nulle
[InlineData("a\u0007b")] // caractère de contrôle
[InlineData("a\u2028b")] // séparateur de ligne Unicode
[InlineData("a\uE000b")] // usage privé
public void Invisible_or_special_characters_are_refused(string value)
{
Assert.Null(FeedbackRules.CleanMultiline(value, 2000, out var problem));
Assert.NotNull(problem);
}
[Fact]
public void Lone_surrogates_are_refused()
{
// (pas en InlineData : une chaîne d'attribut ne peut pas contenir de demi-caractère isolé)
Assert.Null(FeedbackRules.CleanMultiline("a" + '\uD800' + "b", 2000, out var problem));
Assert.NotNull(problem);
Assert.Null(FeedbackRules.CleanMultiline("a" + '\uDC00', 2000, out problem));
Assert.NotNull(problem);
}
[Fact]
public void Lengths_and_lines_are_bounded()
{
Assert.NotNull(FeedbackRules.CleanMultiline(new string('a', 100), 100, out _));
Assert.Null(FeedbackRules.CleanMultiline(new string('a', 101), 100, out var tooLong));
Assert.NotNull(tooLong);
Assert.Null(FeedbackRules.CleanMultiline(string.Join('\n', Enumerable.Repeat("x", FeedbackRules.MaxLines + 1)), 2000, out var lines));
Assert.NotNull(lines);
}
[Fact]
public void Proof_of_work_is_verified()
{
var nonce = new byte[16];
var solution = FeedbackProof.Solve(nonce, 10);
Assert.True(FeedbackProof.Verify(nonce, 10, solution));
Assert.False(FeedbackProof.Verify(nonce, 10, solution + "x"));
Assert.False(FeedbackProof.Verify(nonce, 10, "-1"));
Assert.False(FeedbackProof.Verify(nonce, 10, null));
Assert.False(FeedbackProof.Verify(new byte[16].Select((_, i) => (byte)i).ToArray(), 26, solution)); // autre défi
}
[Fact]
public void References_are_readable_and_valid()
{
for (var i = 0; i < 50; i++) Assert.True(FeedbackRules.IsValidReference(FeedbackRules.NewReference()));
Assert.False(FeedbackRules.IsValidReference("FB-ILOU-0000"));
}
[Fact]
public void Validation_keeps_only_filled_fields_and_checks_tech_info()
{
var errors = new List<string>();
var clean = FeedbackRules.Validate(new FeedbackSubmission
{
GameId = "EZR",
Kind = FeedbackKinds.Review,
Title = " Très bon jeu ",
Fields = new() { ["rating"] = "4", ["topic"] = "gameplay", ["liked"] = " " },
Tech = new FeedbackTechInfo { LauncherVersion = "2.6.0", Os = "Windows 11 64 bits (10.0.26100)", Language = "fr-FR" }
}, errors);
Assert.Empty(errors);
Assert.Equal("Très bon jeu", clean!.Title);
Assert.Equal(new[] { "rating", "topic" }, clean.Fields.Keys.Order().ToArray());
errors.Clear();
Assert.Null(FeedbackRules.Validate(new FeedbackSubmission
{
GameId = "EZR", Kind = FeedbackKinds.Review, Title = "Très bon jeu",
Fields = new() { ["rating"] = "6", ["topic"] = "gameplay" }
}, errors));
Assert.Contains("Note", errors[0]);
}
}
@@ -32,6 +32,7 @@
<ProjectReference Include="..\..\src\GameLauncher.ApiClient\GameLauncher.ApiClient.csproj" />
<ProjectReference Include="..\..\server\GameLauncher.Server\GameLauncher.Server.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Admin\GameLauncher.Modules.Admin.csproj" />
<ProjectReference Include="..\..\src\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<ItemGroup>
@@ -52,6 +52,30 @@ public class AuditModuleTests
Assert.Contains("entrées plus anciennes", views[0].Details);
}
[Fact]
public async Task Entries_name_the_device_chosen_at_enrollment_never_the_windows_name()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor); // appareil « poste de test »
// Launcher 2.4.1 ou plus ancien : nom Windows du PC entre parenthèses dans la description du logiciel.
await using (await api.CreateClient().LoginAsync(editorKey, "GameLauncher 2.4.1 (DESKTOP-ABC123)"))
{
}
var login = (await owner.GetAuditAsync(50)).Entries.First(e => e.Action == "auth.login" && e.KeyId == editorKey.KeyId);
Assert.Equal("poste de test", login.KeyLabel);
Assert.Equal("appareil « poste de test » · GameLauncher 2.4.1", login.Details);
Assert.DoesNotContain(Journal(api, "auth.login"), e => e.Details?.Contains("DESKTOP-ABC123") == true);
// Le nom de l'appareil est ajouté à la lecture : il n'est jamais écrit dans le fichier du journal.
Assert.All(api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries, e => Assert.Null(e.KeyLabel));
// Entrée sans clé : pas d'appareil.
Assert.All((await owner.GetAuditAsync(50)).Entries.Where(e => e.KeyId is null), e => Assert.Null(e.KeyLabel));
}
[Fact]
public async Task Exports_are_recorded_and_checked()
{
@@ -0,0 +1,412 @@
using System.Net;
using System.Net.Http.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
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;
namespace GameLauncher.Tests.Server;
/// <summary>Demandes des joueurs : route anonyme protégée (défi, limites, contrôle), consultation et traitement par les administrateurs.</summary>
public class FeedbackModuleTests
{
private const string Game = "EZR";
private static Task<TestApi> StartAsync(IDictionary<string, string?>? extra = null)
{
var settings = new Dictionary<string, string?>
{
["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"
};
foreach (var (key, value) in extra ?? new Dictionary<string, string?>()) settings[key] = value;
return TestApi.StartAsync(settings);
}
internal static FeedbackSubmission Bug(string title = "Plantage dans l'inventaire", string gameId = Game) => new()
{
GameId = gameId,
Kind = FeedbackKinds.Bug,
Title = title,
Fields = new Dictionary<string, string>
{
["category"] = "crash",
["severity"] = "crash",
["frequency"] = "always",
["steps"] = "1. Lancer une partie\r\n2. Ouvrir l'inventaire",
["actual"] = "Le jeu se ferme."
},
Tech = new FeedbackTechInfo { LauncherVersion = "2.6.0", GameVersion = "0.9.3", Os = "Windows 11 64 bits (10.0.26100)", Language = "fr-FR" }
};
private static List<AuditEntry> Journal(TestApi api, string action) =>
api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries.Where(e => e.Action == action).ToList();
private static async Task<FeedbackSubmission> SolvedAsync(TestApi api, FeedbackSubmission submission)
{
using var response = await api.Http.PostAsync(ApiRoutes.FeedbackChallenge, null);
var challenge = (await response.Content.ReadFromJsonAsync<FeedbackChallenge>(ApiJson.Options))!;
Base64Url.TryDecode(challenge.Nonce, out var nonce);
return submission with { ChallengeId = challenge.ChallengeId, Solution = FeedbackProof.Solve(nonce, challenge.Difficulty) };
}
[Fact]
public async Task Disabled_by_default()
{
await using var api = await TestApi.StartAsync();
using var response = await api.Http.PostAsync(ApiRoutes.FeedbackChallenge, null);
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Equal(ApiErrorCodes.NotConfigured, (await TestApi.ReadErrorAsync(response)).Code);
}
[Fact]
public async Task Anonymous_submission_is_stored_per_game_without_any_ip_and_read_by_admins()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var submitted = await client.SendFeedbackAsync(Bug());
Assert.True(FeedbackRules.IsValidReference(submitted.Reference), submitted.Reference);
// Fichier privé du jeu, sans adresse IP ni aucun identifiant du PC.
var file = Assert.Single(Directory.GetFiles(Path.Combine(api.DataDirectory, "feedback", Game), "*.json", SearchOption.AllDirectories));
var content = await File.ReadAllTextAsync(file);
Assert.DoesNotContain("127.0.0.1", content);
Assert.Contains("1. Lancer une partie\\n2. Ouvrir", content); // fins de ligne normalisées
var submit = Assert.Single(Journal(api, "feedback.submit"));
Assert.Null(submit.Ip);
Assert.Equal(submitted.Reference, submit.Target);
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var overview = await owner.GetFeedbackOverviewAsync();
Assert.True(overview.Enabled);
var counts = Assert.Single(overview.Games);
Assert.Equal((Game, 1, 1, 1), (counts.GameId, counts.New, counts.Open, counts.Total));
var page = await owner.GetFeedbackPageAsync(Game);
var item = Assert.Single(page.Items);
Assert.Equal("Plantage", item.Badge);
var detail = await owner.GetFeedbackAsync(Game, item.Id);
Assert.Equal("Le jeu se ferme.", detail.Fields["actual"]);
Assert.Equal("fr-FR", detail.Tech?.Language);
// Consultations inscrites (sans le contenu), une seule fois pour un réaffichage rapproché.
await owner.GetFeedbackAsync(Game, item.Id);
var views = Journal(api, "feedback.view");
Assert.Equal(2, views.Count); // liste + demande
Assert.All(views, v => Assert.DoesNotContain("se ferme", v.Details ?? ""));
}
[Fact]
public async Task Proof_of_work_is_required_and_single_use()
{
await using var api = await StartAsync();
using (var noProof = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(Bug())))
{
Assert.Equal(HttpStatusCode.BadRequest, noProof.StatusCode);
Assert.Equal(ApiErrorCodes.ProofInvalid, (await TestApi.ReadErrorAsync(noProof)).Code);
}
var solved = await SolvedAsync(api, Bug());
using (var wrong = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(solved with { ChallengeId = solved.ChallengeId[..^2] + "AA" })))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await TestApi.ReadErrorAsync(wrong)).Code);
using (var ok = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(solved)))
Assert.True(ok.IsSuccessStatusCode);
using (var replay = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(solved)))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await TestApi.ReadErrorAsync(replay)).Code);
// Défi expiré.
var late = await SolvedAsync(api, Bug());
api.Time.Advance(TimeSpan.FromMinutes(11));
using (var expired = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(late)))
Assert.Equal(ApiErrorCodes.ProofInvalid, (await TestApi.ReadErrorAsync(expired)).Code);
}
[Theory]
[InlineData("..")]
[InlineData(".")]
[InlineData("AUTRE")]
public async Task Only_configured_games_are_accepted(string gameId)
{
await using var api = await StartAsync();
using var response = await api.Http.PostAsync(ApiRoutes.Feedback, TestApi.Json(await SolvedAsync(api, Bug(gameId: gameId))));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.False(Directory.Exists(Path.Combine(api.DataDirectory, "feedback")) &&
Directory.EnumerateFiles(Path.Combine(api.DataDirectory, "feedback"), "*", SearchOption.AllDirectories).Any());
}
[Fact]
public async Task Game_id_is_matched_without_case_and_stored_as_configured()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug(gameId: "ezr"));
Assert.True(Directory.Exists(Path.Combine(api.DataDirectory, "feedback", Game)));
}
[Fact]
public async Task Invalid_content_is_refused()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
var bad = new[]
{
Bug(title: "abc"), // titre trop court
Bug(title: "Titre \u202E inversé"), // inversion du sens d'écriture
Bug() with { Fields = new(Bug().Fields) { ["steps"] = "étape\u200Bcachée" } }, // caractère invisible
Bug() with { Fields = new(Bug().Fields) { ["unknown"] = "x" } }, // champ inconnu
Bug() with { Fields = new(Bug().Fields) { ["severity"] = "terrible" } }, // choix inconnu
Bug() with { Fields = new(Bug().Fields) { ["steps"] = new string('a', 2001) } },
Bug() with { Kind = "complaint" },
Bug() with { Tech = new FeedbackTechInfo { Os = "C:\\Users\\nicolas" } }, // chemin : refusé
Bug() with { FormVersion = 99 }
};
foreach (var submission in bad)
{
var ex = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(submission));
Assert.Equal(400, ex.Status);
}
Assert.Empty(Journal(api, "feedback.submit").Where(e => e.Result == AuditResults.Ok));
}
[Fact]
public async Task Each_address_is_limited_and_nothing_is_kept_on_disk_about_it()
{
await using var api = await StartAsync(new Dictionary<string, string?> { ["LauncherApi:Feedback:PerIpPer10Minutes"] = "2" });
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug());
await client.SendFeedbackAsync(Bug());
var ex = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(Bug()));
Assert.Equal(429, ex.Status);
Assert.NotNull(ex.RetryAfter);
api.Time.Advance(TimeSpan.FromMinutes(11));
await client.SendFeedbackAsync(Bug());
Assert.DoesNotContain(Directory.EnumerateFiles(api.DataDirectory, "*", SearchOption.AllDirectories)
.Where(f => !f.Contains("data-protection")),
f => File.ReadAllText(f).Contains("127.0.0.1"));
}
[Fact]
public async Task Ceiling_per_game_refuses_new_requests()
{
await using var api = await StartAsync(new Dictionary<string, string?> { ["LauncherApi:Feedback:MaxPerGame"] = "1" });
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug());
var ex = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(Bug()));
Assert.Equal((503, ApiErrorCodes.FeedbackFull), (ex.Status, ex.Code));
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
Assert.True((await owner.GetFeedbackOverviewAsync()).StorageFull);
}
[Fact]
public async Task Status_and_note_are_signed_checked_and_audited()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug());
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var item = Assert.Single((await owner.GetFeedbackPageAsync(Game)).Items);
var updated = await owner.UpdateFeedbackAsync(Game, item.Id, item.Revision, FeedbackStatuses.InProgress, "Reproduit sur la 0.9.3");
Assert.Equal(FeedbackStatuses.InProgress, updated.Summary.Status);
Assert.Equal(owner.Profile.DisplayName, updated.StatusChangedBy);
Assert.True(updated.Summary.HasNote);
// Révision périmée : rien n'est écrasé.
var conflict = await Assert.ThrowsAsync<ApiException>(() =>
owner.UpdateFeedbackAsync(Game, item.Id, item.Revision, FeedbackStatuses.Done, null));
Assert.Equal(ApiErrorCodes.RevisionConflict, conflict.Code);
var invalid = await Assert.ThrowsAsync<ApiException>(() =>
owner.UpdateFeedbackAsync(Game, item.Id, updated.Summary.Revision, "lost", null));
Assert.Equal(400, invalid.Status);
// Sans signature : refus (un jeton volé ne permet pas de modifier).
var raw = await api.RawLoginAsync(ownerKey);
using var unsigned = await api.SendSignedRawAsync(raw, ownerKey, HttpMethod.Put, ApiRoutes.GameFeedbackItem(Game, item.Id),
new FeedbackUpdateRequest { Revision = updated.Summary.Revision, Status = FeedbackStatuses.Done }, includeSignature: false);
Assert.Equal(HttpStatusCode.Unauthorized, unsigned.StatusCode);
var audit = Assert.Single(Journal(api, "feedback.update"));
Assert.Contains("Nouvelle → En cours", audit.Details);
Assert.DoesNotContain("Reproduit", audit.Details);
await owner.DeleteFeedbackAsync(Game, item.Id);
Assert.Empty((await owner.GetFeedbackPageAsync(Game)).Items);
Assert.Single(Journal(api, "feedback.delete"));
}
[Fact]
public async Task Editors_cannot_read_requests_by_default()
{
await using var api = await StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor);
await using var editor = await api.LoginAsync(editorKey);
var ex = await Assert.ThrowsAsync<ApiException>(() => editor.GetFeedbackPageAsync(Game));
Assert.Equal(403, ex.Status);
var (adminKey, _) = await api.AddAdminAsync(owner, "Admin", AdminRoles.Admin);
await using var admin = await api.LoginAsync(adminKey);
Assert.Empty((await admin.GetFeedbackPageAsync(Game)).Items); // groupe « Administrateur » créé avec les nouvelles permissions
}
[Fact]
public async Task Pages_follow_each_other_and_filters_apply()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
for (var i = 0; i < 5; i++)
{
await client.SendFeedbackAsync(Bug(title: $"Problème numéro {i}"));
api.Time.Advance(TimeSpan.FromSeconds(1));
}
await client.SendFeedbackAsync(new FeedbackSubmission
{
GameId = Game, Kind = FeedbackKinds.Review, Title = "Très bon jeu",
Fields = new() { ["rating"] = "5", ["topic"] = "overall" }
});
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var first = await owner.GetFeedbackPageAsync(Game, limit: 4);
Assert.Equal((4, 6), (first.Items.Count, first.Total));
Assert.Equal("Très bon jeu", first.Items[0].Title); // plus récente d'abord
var second = await owner.GetFeedbackPageAsync(Game, cursor: first.NextCursor, limit: 4);
Assert.Equal(2, second.Items.Count);
Assert.Null(second.NextCursor);
Assert.Empty(first.Items.Select(i => i.Id).Intersect(second.Items.Select(i => i.Id)));
var reviews = await owner.GetFeedbackPageAsync(Game, kind: FeedbackKinds.Review);
Assert.Equal("5/5", Assert.Single(reviews.Items).Badge);
Assert.Equal(6, (await owner.GetFeedbackPageAsync(Game, status: FeedbackStore.OpenFilter)).Total);
Assert.Equal(0, (await owner.GetFeedbackPageAsync(Game, status: FeedbackStore.ClosedFilter)).Total);
}
[Fact]
public async Task Retention_purges_old_requests_and_old_audit_months()
{
await using var api = await StartAsync();
using var client = api.CreateClient();
await client.SendFeedbackAsync(Bug(title: "Ancienne demande close"));
await client.SendFeedbackAsync(Bug(title: "Ancienne demande ouverte"));
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var closed = (await owner.GetFeedbackPageAsync(Game)).Items.Single(i => i.Title.Contains("close"));
await owner.UpdateFeedbackAsync(Game, closed.Id, closed.Revision, FeedbackStatuses.Done, null);
// Ancien fichier d'audit (13 mois) : supprimé ; celui du mois en cours : gardé.
var auditFolder = Path.Combine(api.DataDirectory, "audit");
var old = api.Time.GetUtcNow().AddMonths(-13).ToString("yyyy-MM");
await File.WriteAllTextAsync(Path.Combine(auditFolder, $"audit-{old}.jsonl"), "{}\n");
var retention = api.Services.GetServices<IHostedService>().OfType<RetentionService>().Single();
api.Time.Advance(TimeSpan.FromDays(370)); // > 12 mois après la clôture
retention.RunOnce();
var store = api.Services.GetRequiredService<FeedbackStore>(); // session expirée, horloge du test avancée
var remaining = store.List(Game, null, null, null, 100).Items;
Assert.Equal("Ancienne demande ouverte", Assert.Single(remaining).Title);
Assert.False(File.Exists(Path.Combine(auditFolder, $"audit-{old}.jsonl")));
Assert.Single(Journal(api, "feedback.purge"));
api.Time.Advance(TimeSpan.FromDays(370)); // > 24 mois après réception
retention.RunOnce();
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);
}
}
+212 -3
View File
@@ -7,6 +7,7 @@ using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Files;
using GameLauncher.Contracts.Publishing;
using GameLauncher.Contracts.Security;
@@ -18,7 +19,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 +165,26 @@ 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", "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 +220,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 +240,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 +257,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 +271,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 +586,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 +605,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 +622,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)");
@@ -672,4 +717,168 @@ internal sealed class SampleWorld
};
private static string Random(int bytes) => Base64Url.Encode(RandomNumberGenerator.GetBytes(bytes));
// ------------------------------------------------------------------ Demandes des joueurs
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;
}
}
+44
View File
@@ -1,7 +1,11 @@
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.Core.Models;
@@ -253,6 +257,46 @@ internal static class SampleData
public static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
// ------------------------------------------------------------------ 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)));
}
+16 -2
View File
@@ -37,10 +37,15 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
bug rempli avec une capture d'écran (formulaire qui défile : pages `.p2.png`…), champs obligatoires
signalés, demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
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), une
version prête à publier, et la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
lien signalé, captures d'écran).
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
@@ -57,6 +62,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)
+30 -1
View File
@@ -11,6 +11,7 @@ using GameLauncher.Modules.Admin.Accounts;
using GameLauncher.Modules.Admin.Audit;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Changelog;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.Signing;
using GameLauncher.Modules.Admin.ViewModels;
@@ -44,6 +45,7 @@ internal sealed class AdminScenes : ISceneSet
("admin-enroll-done", "Administration · clé créée, empreinte à communiquer"),
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
(PagePrefix + "feedback-requests-detail", "Administration · demande d'un joueur (lien signalé, captures d'écran)"),
("admin-login-locked", "Administration · session verrouillée")
};
@@ -210,6 +212,33 @@ internal sealed class AdminScenes : ISceneSet
await ShootAllThemesAsync(publishId, "Administration · version prête à publier");
}
// ---- Demandes des joueurs : fiche d'une demande avec un lien (avertissement) et des captures d'écran
var feedbackId = PagePrefix + "feedback-requests-detail";
if (capture.WantsAny(capture.Themes.Select(t => feedbackId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == FeedbackPanelViewModel.PanelId) is { } feedbackItem)
{
workspace.SelectedPanel = feedbackItem;
await WaitForPanelAsync(capture, feedbackItem, api);
if (feedbackItem.ViewModel is FeedbackPanelViewModel feedback)
{
if (feedback.SelectedGame is null && MainChoice(feedback.GameChoices) is { } choice)
{
feedback.SelectedGame = choice; // plusieurs jeux : choisi comme le ferait l'administrateur
await WaitForPanelAsync(capture, feedbackItem, api);
}
if ((feedback.Items.FirstOrDefault(i => i.HasLinks && i.HasAttachments) ?? feedback.Items.FirstOrDefault()) is { } request)
{
feedback.SelectedItem = request; // fiche, puis captures chargées et revérifiées une à une
await WaitForPanelAsync(capture, feedbackItem, api);
}
else
{
capture.GlobalNotes.Add("⚠ Demandes des joueurs : aucune demande à afficher (voir SampleWorld.CreateFeedback).");
}
}
await ShootAllThemesAsync(feedbackId, "Administration · demande d'un joueur (lien signalé, captures d'écran)");
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
@@ -254,7 +283,7 @@ internal sealed class AdminScenes : ISceneSet
return new ModuleCatalog(new ILauncherModule[]
{
new AdminModule(), new CatalogAdminModule(), new ChangelogAdminModule(), new PublishAdminModule(),
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule()
new AccountsAdminModule(), new SigningAdminModule(), new AuditAdminModule(), new FeedbackAdminModule()
});
}
+139
View File
@@ -0,0 +1,139 @@
using System.Windows;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Feedback;
using GameLauncher.Views;
using UiSnapshot.Fakes;
namespace UiSnapshot.Scenes;
/// <summary>
/// Fenêtre « Aide » du joueur (bouton de la barre de titre) : choix du type, formulaires (avis noté, bug rempli
/// avec une capture d'écran, champs obligatoires signalés), demande envoyée. Vrai ViewModel et vraie fenêtre ;
/// l'envoi est simulé (aucun réseau) et la capture d'écran est une image de démonstration (ni fichier choisi,
/// ni presse-papiers). Un formulaire plus haut que la fenêtre donne des pages supplémentaires (.p2.png…).
/// </summary>
internal sealed class FeedbackScenes : ISceneSet
{
/// <summary>Référence remise par l'envoi simulé (alphabet des références : ni I, L, O, U).</summary>
public const string SampleReference = "FB-7K2M-Q9XD";
private readonly Fixture _fixture;
public FeedbackScenes(Fixture fixture) => _fixture = fixture;
private static readonly (string Id, string Title, Func<FeedbackFormViewModel, Task> Apply)[] SceneList =
{
("feedback-kind", "Aide · choix du type de demande", _ => Task.CompletedTask),
("feedback-review", "Aide · avis noté", form =>
{
Choose(form, FeedbackKinds.Review);
form.Title = "Une ambiance vraiment réussie";
Field(form, "rating").Rating = 4;
Option(form, "topic", "overall");
Field(form, "liked").Text = "L'ambiance sonore et la lumière dans les couloirs de l'école.";
Option(form, "recommend", "yes");
return Task.CompletedTask;
}),
("feedback-bug", "Aide · bug rempli, avec capture d'écran (formulaire qui défile)", form =>
{
FillBug(form);
form.AddFileCommand.Execute(null);
return Task.CompletedTask;
}),
("feedback-bug-errors", "Aide · bug, champs obligatoires signalés", async form =>
{
Choose(form, FeedbackKinds.Bug);
form.Title = "Bug";
await form.SendCommand.ExecuteAsync(); // contrôle local : rien n'est envoyé
}),
("feedback-done", "Aide · demande envoyée (une capture refusée par le serveur)", async form =>
{
Choose(form, FeedbackKinds.Other);
form.Title = "Un serveur Discord officiel ?";
Field(form, "message").Text = "Est-ce qu'il y aura un serveur Discord officiel ?";
form.AddFileCommand.Execute(null);
form.PasteCommand.Execute(null);
await form.SendCommand.ExecuteAsync();
})
};
public IEnumerable<SceneInfo> List(IReadOnlyList<string?> themes) =>
themes.SelectMany(theme => SceneList.Select(s => new SceneInfo(s.Id + Capture.Suffix(theme), s.Title)));
public async Task RunAsync(Capture capture)
{
foreach (var theme in capture.Themes)
{
foreach (var (id, title, apply) in SceneList)
{
await capture.ShootAsync(id + Capture.Suffix(theme), title, async () =>
{
_fixture.ApplyTheme(theme);
var form = NewForm();
await apply(form);
if (form.HasError && id != "feedback-bug-errors") capture.Problem($"Formulaire « Aide » : {form.ErrorMessage}");
if (form.HasAttachmentError) capture.Problem($"Capture d'écran refusée : {form.AttachmentError}");
return new FeedbackWindow(form); // la fenêtre libère le ViewModel à sa fermeture
}, Capture.CloseAsync);
}
}
}
private FeedbackFormViewModel NewForm()
{
var game = _fixture.MainGame;
var target = new FeedbackTarget(game.GameId, game.ShownName, "1.4.2", _fixture.LauncherVersion);
// Informations techniques fixes (aucune lecture de la machine : captures identiques d'un PC à l'autre).
var tech = new FeedbackTechInfo
{
LauncherVersion = FeedbackRules.SafeVersion(_fixture.LauncherVersion),
GameVersion = "1.4.2",
Os = "Windows 11 64 bits (10.0.26100)",
Language = "fr-FR"
};
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots());
}
private static void FillBug(FeedbackFormViewModel form)
{
Choose(form, FeedbackKinds.Bug);
form.Title = "Le jeu se fige à l'ouverture de l'inventaire";
Option(form, "category", "gameplay");
Option(form, "severity", "blocking");
Option(form, "frequency", "often");
Field(form, "steps").Text = "1. Je lance une partie dans la cour de l'école.\n2. Je ramasse la clé du gymnase.\n" +
"3. J'ouvre l'inventaire (touche I).";
Field(form, "expected").Text = "L'inventaire s'ouvre et la partie continue.";
Field(form, "actual").Text = "L'image se fige, le son continue, il faut fermer le jeu avec le gestionnaire des tâches.";
}
private static void Choose(FeedbackFormViewModel form, string kind) =>
form.Kinds.Single(k => k.Form.Kind == kind).SelectCommand.Execute(null);
private static FeedbackFieldViewModel Field(FeedbackFormViewModel form, string id) => form.Fields.Single(f => f.Id == id);
private static void Option(FeedbackFormViewModel form, string fieldId, string optionId)
{
var field = Field(form, fieldId);
field.SelectedOption = field.Options.Single(o => o.Id == optionId);
}
/// <summary>Envoi simulé : référence fixe ; sur deux captures, la seconde est « refusée » (avertissement affiché).</summary>
private sealed class SampleSender : IFeedbackSender
{
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct) =>
Task.FromResult(attachments.Count > 1
? new FeedbackSent(SampleReference, attachments.Count - 1, "Dimensions refusées : 16 à 4096 px de côté.")
: new FeedbackSent(SampleReference, attachments.Count, null));
}
/// <summary>« Choisir une image » ou « Coller » : image de démonstration (aucun fichier, presse-papiers intact).</summary>
private sealed class SampleScreenshots : IScreenshotSource
{
public ScreenshotResult? PickFile() =>
new(SampleData.CreateScreenshot(1280, 720, "Inventaire figé"), "capture-inventaire.png", null);
public ScreenshotResult? FromClipboard() =>
new(SampleData.CreateScreenshot(800, 600, "Erreur : NullReferenceException"), "Image collée", null);
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ internal sealed class SnapshotRunner
var themes = ResolveThemes();
var repository = Fixture.FindRepositoryRoot();
using var fixture = new Fixture(repository);
var sets = new ISceneSet[] { new PlayerScenes(fixture), new AdminScenes(fixture) };
var sets = new ISceneSet[] { new PlayerScenes(fixture), new FeedbackScenes(fixture), new AdminScenes(fixture) };
if (_options.List)
{
+2 -2
View File
@@ -2,8 +2,8 @@
<!--
Outil de développement « UiSnapshot » : rend chaque écran du launcher en PNG (fenêtre principale dans
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, menu d'administration et
chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
tous ses états, paramètres, boîtes de dialogue, mise à jour du launcher, fenêtre « Aide », menu
d'administration et chacune de ses pages), avec des données de démonstration, sans serveur ni compte réel, puis signale
les problèmes de mise en page (texte coupé, débordement, erreurs de liaison…).
Lancement : tools\ui-snapshot.bat (voir LISEZMOI.md).
Jamais distribué aux joueurs : projet séparé, que le launcher ne référence pas.
+110 -46
View File
@@ -1,67 +1,131 @@
# Launcher 2.4.1
# Launcher 3.1.0
LAUNCHER - NOTES DE MISE À JOUR
Avertissement avant une publication signée - Mise à jour du 7 novembre 2026
Captures d'écran et liens signalés - Mise à jour du 9 novembre 2026
==================================================================
Petite version du menu d'administration : avant de publier le catalogue ou une version, le launcher vérifie que la clé de votre PC est bien reconnue par les launchers des joueurs, et vous prévient sinon.
Depuis le bouton « Aide », vous pouvez maintenant joindre jusqu'à 3 captures d'écran à votre demande. Chaque image est contrôlée et débarrassée de ses métadonnées (ni position GPS, ni appareil, ni date) avant l'envoi.
Rien ne change dans le fonctionnement : le serveur accepte toujours ces publications et les launchers des joueurs les traitent comme avant. Il s'agit uniquement d'un avertissement. Pas de serveur à redéployer.
Dans le menu d'administration, une demande contenant un lien est signalée par un avertissement, et le journal d'audit affiche des libellés lisibles. La fenêtre « Aide » reste centrée et défile avec un ascenseur. Le serveur est à redéployer, avec un bloc nginx à ajouter, avant de publier cette version.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : avertissement « Clé non reconnue par les launchers des
joueurs » avant de publier le catalogue (page « Jeux »).
- NOUVEAU : même avertissement avant de publier une version et
avant « Signer la version en ligne » (page « Publier une
version »).
- AMÉLIORÉ : le message explique la conséquence pour les joueurs
et la marche à suivre.
- SERVEUR : aucun changement, rien à redéployer.
- QUALITÉ : 5 nouveaux tests automatisés (281 au total).
- NOUVEAU : captures d'écran jointes à une demande (3 au plus,
fichier ou Ctrl+V) : vraie image exigée, PNG ou JPEG seulement,
sans métadonnées.
- NOUVEAU : captures affichées dans la fiche d'une demande
(administration), revérifiées avant l'affichage.
- NOUVEAU : avertissement quand une demande contient un lien ; liens
désamorcés, jamais cliquables.
- AMÉLIORÉ : journal d'audit avec des libellés lisibles (« Demande de
joueur reçue »…) et une catégorie « Demandes des joueurs ».
- AMÉLIORÉ : fenêtre « Aide » centrée, formulaire long avec un
ascenseur.
- SÉCURITÉ : image contrôlée par le launcher et par le serveur ;
envoi autorisé par un jeton propre à la demande.
- SERVEUR : à redéployer, avec un nouveau bloc nginx.
- QUALITÉ : 30 nouveaux tests automatisés (342 au total).
------------------------------------------------------------------
AVERTISSEMENT AVANT PUBLICATION
CAPTURES D'ÉCRAN
------------------------------------------------------------------
- Juste avant la signature, le launcher télécharge le trousseau
publié et le vérifie comme les launchers des joueurs.
- L'avertissement s'affiche si :
* la clé du PC n'est pas dans le trousseau publié ;
* la clé y est, mais sans l'autorisation voulue (catalogue ou
versions du jeu) ;
* le trousseau est refusé (autre clé racine, trop ancien,
illisible) ou injoignable ;
* aucun trousseau n'est configuré dans le launcher.
- Le message indique ce qui se passera chez les joueurs :
* vérification obligatoire : catalogue ignoré (les joueurs
gardent le précédent) ou version refusée (ni installation ni
mise à jour, le jeu déjà installé reste jouable) ;
* vérification en mode « warn » : contenu accepté, avec une
alerte dans le journal ;
* vérification désactivée : aucun avertissement.
- Deux choix : « Annuler » (rien n'est envoyé) ou « Publier quand
même » (« Signer quand même » pour une version déjà en ligne).
- Pour régler le problème : un propriétaire ajoute la clé du PC au
trousseau (page « Signature »), le fait signer avec la clé
racine puis le publie ; les joueurs acceptent alors le contenu
sans nouvelle publication.
- SÉCURITÉ :
* aucun changement côté serveur ni côté joueur ;
* trousseau vérifié avec la clé racine intégrée au launcher ;
* clé du PC identifiée localement, à partir de la clé qui
signe ;
* téléchargement sans cache, limité en taille et en durée ;
* une erreur imprévue du contrôle ne bloque pas la publication.
- Nouvelle section « Captures d'écran (facultatif) » dans le
formulaire du bouton « Aide », pour tous les types de demande.
- « Ajouter une image… » : PNG, JPEG, BMP, GIF ou WebP (30 Mo au
plus).
- « Coller (Ctrl+V) » : image du presse-papiers, après Impr. écran ou
Win+Maj+S ; Ctrl+V fonctionne aussi depuis un champ de texte.
- Miniature de chaque image, avec une croix pour la retirer avant
l'envoi.
- Image reconnue à son contenu (un fichier renommé est refusé),
réduite à 3 840 px au plus et réencodée en PNG ou JPEG sans
métadonnées : ni position GPS, ni appareil, ni date.
- Le nom du fichier n'est jamais envoyé.
- Si une capture ne peut pas être envoyée, un avertissement le
précise : la demande, elle, est bien enregistrée.
------------------------------------------------------------------
PAGE « DEMANDES DES JOUEURS » (ADMINISTRATION)
------------------------------------------------------------------
- Liste : icône « image » pour une demande avec des captures, icône
« alerte » pour une demande contenant un lien.
- Fiche : captures d'écran en miniature, « Agrandir » / « Réduire » ;
chaque image est revérifiée avant l'affichage et rien n'est écrit
sur le PC.
- Liens : bandeau d'avertissement en haut de la fiche, liens
désamorcés (hxxps[://]site[.]com) et champs concernés surlignés ;
jamais cliquables.
- Formes reconnues : http, https, ftp, file, protocoles dangereux
(javascript:, data:…), www, domaines nus (site.ru, discord.gg/…),
adresses IP, chemins réseau, e-mails, formes déguisées (hxxp, [.],
(dot), caractères pleine chasse).
------------------------------------------------------------------
JOURNAL D'AUDIT ET FENÊTRE « AIDE »
------------------------------------------------------------------
- Libellés lisibles : Demande de joueur reçue, consultée, traitée,
supprimée ; Capture d'écran reçue ; Anciennes demandes supprimées ;
Anciens mois du journal supprimés ; Trousseau de signature publié ;
Version en ligne signée.
- Nouvelles catégories : « Demandes des joueurs » et « Signature
(trousseau) ».
- Fenêtre « Aide » centrée à chaque étape ; formulaire limité à une
hauteur de base, avec un ascenseur au-delà.
- La molette fait défiler le formulaire et ne change jamais une liste
déroulante fermée.
------------------------------------------------------------------
SÉCURITÉ ET VIE PRIVÉE
------------------------------------------------------------------
- Le serveur n'accepte que de vrais PNG ou JPEG : structure complète
vérifiée, rien après la fin de l'image, 16 à 4 096 px, 4 Mo au plus.
- Métadonnées retirées par le launcher puis par le serveur.
- Jeton d'envoi propre à la demande, valable 15 minutes, pour le
nombre de captures annoncé seulement.
- Plafond global porté à 1 Go (captures comprises).
- Capture affichée dans le menu seulement après un nouveau contrôle,
jamais écrite sur le disque.
- Aucune adresse IP enregistrée avec une capture ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
- Captures supprimées avec leur demande (suppression ou purge).
- Statut, note et suppression restent des requêtes signées.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer AVANT de publier le launcher : server/deploy/
LISEZMOI.md, étape 8, puis étape 3 octies.
- nginx : ajouter le troisième bloc
« location ^~ /api/v1/feedback/attachments/ » (nginx-api.conf).
- Si Feedback:MaxTotalMegabytes est fixé dans
appsettings.Production.json, le porter à 1024.
- Sans redéploiement, les demandes arrivent, mais sans leurs
captures (le joueur est prévenu).
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Tests : nouveau fichier PlayerSigningCheckTests.cs.
- Documentation : docs/ADMIN-ARCHITECTURE.md mis à jour
(avertissement avant publication).
- Contrats : FeedbackLinks (détection des liens), FeedbackImages
(contrôle des PNG/JPEG et retrait des métadonnées, sans
bibliothèque).
- Serveur : envoi et lecture des captures (FeedbackModule), jetons
d'envoi (FeedbackGuard), stockage à côté des demandes
(FeedbackStore).
- Launcher : ScreenshotSource (fichier ou presse-papiers, réencodage
sans métadonnées).
- Outil ui-snapshot : fenêtre « Aide » et fiche d'une demande
capturées ; section « Tenir l'outil à jour » dans son LISEZMOI.
- Tests : FeedbackContentTests.cs (nouveau), FeedbackModuleTests.cs,
FeedbackPanelTests.cs.
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
(§ 11 quinquies) et server/deploy/LISEZMOI.md mis à jour.
La sécurité des mises à jour du launcher ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.