Compare commits

...
29 Commits
Author SHA1 Message Date
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
nicolas 987dab286b Version 2.4.1 2026-11-07 20:54:40 +01:00
nicolas 6592b09d14 docs: avertissement avant publication (trousseau des joueurs) 2026-11-07 20:48:30 +01:00
nicolas 35b47b1b9d test(admin): avertissement avant publication signée 2026-11-07 20:48:17 +01:00
nicolas b67636e29b feat(launcher): branchement du contrôle du trousseau dans le menu d'administration 2026-11-07 20:47:58 +01:00
nicolas db37cd9d46 feat(admin): avertissement clé non reconnue (pages Jeux et Publier) 2026-11-07 20:47:43 +01:00
nicolas ffbd98d800 feat(admin): contrôle du trousseau des joueurs avant publication 2026-11-07 20:47:15 +01:00
fred c281ca1781 Merge pull request 'Dev/2.4.0' (#121) from dev/2.4.0 into master
Reviewed-on: #121
Reviewed-by: Deville <fred@lanisys.fr>
2026-11-07 18:46:20 +01:00
77 changed files with 6202 additions and 291 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
+32 -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,17 @@ 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`. 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. 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 +621,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é**
+146 -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,78 @@ 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 |
| `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 |
| `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, 100 Mo, 500 Mo libres : 503
`feedback_full`). Le serveur pose lui-même l'identifiant (128 bits), la référence (`FB-XXXX-XXXX`, 40 bits), la date
et le statut.
**Anonymat** : ni adresse IP ni identifiant du PC dans la demande ; audit `feedback.submit` **sans IP** (référence,
jeu, type) ; refus résumés par `WriteAnonymousFailure` sans IP ; nginx `access_log off` sur les deux routes ;
informations techniques facultatives (versions du launcher et du jeu, Windows, langue), cochées par défaut pour un
bug seulement, contenu exact montré au joueur avant l'envoi. Agent utilisateur générique (`GameLauncher/<version>`).
Le launcher ne contacte l'API qu'à l'envoi.
**Stockage** (`Storage/FeedbackStore`) : un fichier JSON par demande, `feedback/<jeu>/<AAAA-MM>/<id>.json` dans les
données privées (0700/0600), écriture atomique, index en mémoire construit au premier accès (fichier illisible ignoré
et signalé), verrou en mémoire (seul le service écrit ici). **SQLite évalué et écarté pour ce module** : volumes bornés,
aucune relation, une demande = un document modifié isolément, purge = suppression de fichiers ; SQLite ajouterait une
dépendance native (paquet NuGet + bibliothèque `e_sqlite3`) à maintenir sur le VPS et une sauvegarde spécifique, sans
gain de sécurité. À réévaluer avec le module « comptes + échanges » (relations comptes ⇄ demandes ⇄ messages,
requêtes croisées, volumes non bornés), où SQLite serait légitime (requêtes paramétrées uniquement, fichier 0600 dans
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.
**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). 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 :
@@ -674,6 +804,18 @@ et peut exiger la signature (`LauncherApi:ContentSigning:Require`). Il retire to
et n'y met que celle de la mise en ligne. L'empreinte « contenu en ligne inchangé pendant l'envoi » ignore les
notes et la signature. Un nouvel essai de mise en ligne plus de 5 minutes après la signature la refait.
**Avertissement avant publication** (`Modules/Admin/PlayerSigningCheck`, côté administration uniquement) : avant de
publier le catalogue (page Jeux), une version ou de signer la version en ligne (page « Publier une version »), le
launcher télécharge le trousseau publié (`keyringUrl` du launcher.json intégré, sans cache, 15 s au plus) et le
vérifie comme les launchers des joueurs (structure, racine intégrée, `keyringMinSequence`). Si la clé de ce PC
(identifiant calculé localement) en est absente, n'a pas l'autorisation voulue (`catalog` / `releases`), ou si le
trousseau est injoignable ou refusé : confirmation « Clé non reconnue par les launchers des joueurs » qui explique la
conséquence selon `contentSigning` (« require » : catalogue ignoré / version refusée ; « warn » : alerte au journal)
et le remède (page Signature), avec « Publier quand même » / « Signer quand même ». Aucun avertissement en
`contentSigning` « off ». **Le serveur n'est pas modifié** : il accepte toujours ces publications (il ne contrôle que
la clé de la session, la date et la validité de la signature) ; une erreur imprévue du contrôle ne bloque pas la
publication.
**Côté joueur** (`Core/Trust/ContentTrustService`) : `keyringUrl` et `contentSigning` lus, en Release,
uniquement dans le `launcher.json` intégré (`off` par défaut, `warn` si `keyringUrl` est présent, `require`).
Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (structure, racine), mis en cache ; refusé
@@ -711,6 +853,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.
@@ -722,5 +865,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; } = 100;
/// <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,223 @@
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.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).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"));
return Results.Json(new FeedbackSubmitted { Reference = record.Reference }, 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 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,132 @@
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;
}
}
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,442 @@
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; }
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),
Revision = Revision
};
public FeedbackDetail ToDetail() => new()
{
Summary = ToSummary(),
FormVersion = FormVersion,
Fields = Fields,
Tech = Tech,
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)
{
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 >= (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,
Status = FeedbackStatuses.New,
Revision = 1
};
Write(record);
game[record.Id] = record;
return record;
}
}
// ------------------------------------------------------------------ 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 void DeleteFile(FeedbackRecord record)
{
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");
records[id] = record with { Fields = record.Fields ?? new Dictionary<string, string>() };
total += info.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;
}
}
}
@@ -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": 100,
"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.
+53 -1
View File
@@ -363,6 +363,56 @@ 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 100 Mo au plus, 500 Mo d'espace disque libre minimum, corps de 16 Ko, contrôle complet du
contenu (caractères invisibles refusés). **Aucune adresse IP** n'est enregistrée avec une demande ni dans l'audit de
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 **deux** blocs `location = /api/v1/feedback…` de `server/deploy/nginx-api.conf` à côté du bloc
`/api/`, et `sudo nginx -t && sudo systemctl reload nginx`.
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 +516,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 +534,12 @@ 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)
└─ 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)
+47 -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,48 @@ 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;
}
@@ -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,31 @@ 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);
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));
@@ -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,23 @@ 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)
{
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);
+26
View File
@@ -108,6 +108,32 @@ 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>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}";
// ------------------------------------------------------------------ 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,173 @@
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>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; } = "";
}
// ------------------------------------------------------------------ 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; }
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>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,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,263 @@
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).");
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 }
};
@@ -428,7 +428,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 +450,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; }
@@ -524,14 +531,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();
@@ -191,6 +191,8 @@ public sealed class GamesPanelViewModel : AdminPanelViewModel
Context.Dialogs.ShowMessage("Valeurs à corriger", string.Join("\n", errors.Distinct().Take(12)), ModuleMessageKind.Warning);
return;
}
// Clé de ce PC absente du trousseau publié : les launchers des joueurs ignoreraient ce catalogue (avertissement seulement).
if (!await ConfirmPlayerSigningAsync(ContentKinds.Catalog)) return;
try
{
@@ -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,209 @@
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>« 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>
public sealed record FeedbackFieldLine(string Label, string Value, bool IsLong);
/// <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);
}
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));
// 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')));
return lines;
}
}
@@ -0,0 +1,418 @@
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;
Detail = NewDetail(detail);
});
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);
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");
}
@@ -246,6 +246,7 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
private async Task SignOnlineAsync()
{
if (_release is not { } release || _selectedGame?.Value is not { } gameId || _online is not { } online) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest, "Signer quand même")) return;
if (!Context.Dialogs.Confirm($"Signer la version en ligne {online.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\n" +
"Vous attestez que la version en ligne est bien celle de ce dossier de publication (le serveur vérifie que les fichiers, " +
@@ -326,6 +327,8 @@ public sealed class PublishPanelViewModel : AdminPanelViewModel
"" => "aucune version en ligne",
_ => $"version en ligne : {_publishedVersion}"
};
// Clé de ce PC absente du trousseau publié : les launchers des joueurs refuseraient cette version (avertissement seulement).
if (!await ConfirmPlayerSigningAsync(ContentKinds.Manifest)) return;
if (!Context.Dialogs.Confirm($"Publier la version {release.Summary.Version} ?",
$"Jeu : {SelectedGameLabel}.\n\nLes joueurs la recevront à leur prochaine recherche de mise à jour ({online}).\n\n" +
"Les fichiers absents du serveur sont envoyés, puis le manifest est mis en ligne en dernier.", "Publier"))
@@ -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
@@ -41,6 +41,12 @@ public sealed class AdminEnvironment
/// </summary>
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; init; } = Array.Empty<(string, byte[])>();
/// <summary>
/// Avertissement avant de publier un contenu signé par une clé que les launchers des joueurs n'accepteront pas
/// (absente du trousseau publié, ou sans l'autorisation voulue). Null : aucun avertissement.
/// </summary>
public PlayerSigningCheck? PlayerSigning { get; init; }
/// <summary>Puce de sécurité (TPM) de ce PC ; null : clés TPM non proposées.</summary>
public IAdminTpm? Tpm { get; init; }
@@ -53,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}";
}
@@ -126,6 +126,7 @@ public sealed class AdminWorkspaceViewModel : ObservableObject, IAdminContext, I
public IModuleDialogs Dialogs => _shell.Environment.Dialogs;
public string LauncherVersion => _shell.Environment.LauncherVersion;
public IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys => _shell.Environment.RootKeys;
public PlayerSigningCheck? PlayerSigning => _shell.Environment.PlayerSigning;
public IReadOnlyList<AdminPanelDescriptor> Panels { get; }
public IDisposable BeginLongOperation() => Guard.BeginLongOperation();
@@ -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,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,348 @@
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>Envoi d'une demande (Launcher API en production, simulé dans les tests). Renvoie la référence.</summary>
public interface IFeedbackSender
{
Task<string> SendAsync(FeedbackSubmission submission, 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<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) =>
(await _client.SendFeedbackAsync(submission, ct).ConfigureAwait(false)).Reference;
}
/// <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 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)
{
Target = target;
_sender = sender;
_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.";
// ------------------------------------------------------------------ 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));
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
{
Reference = await _sender.SendAsync(submission, _sending.Token);
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}");
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>
@@ -1,3 +1,4 @@
using GameLauncher.Core.Utils;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin;
@@ -84,6 +85,38 @@ public abstract class AdminPanelViewModel : ObservableObject
protected virtual Task OnActivatedAsync(bool firstTime) => Task.CompletedTask;
/// <summary>
/// Avant une publication signée (catalogue, version d'un jeu) : si les launchers des joueurs n'accepteront pas la
/// clé de ce PC (absente du trousseau publié, ou sans l'autorisation voulue), avertissement avec la possibilité de
/// continuer. Vrai : publier ; faux : annulé. Simple avertissement : le serveur, lui, accepte la publication.
/// </summary>
/// <param name="kind">ContentKinds.Catalog ou ContentKinds.Manifest.</param>
/// <param name="confirmText">Bouton pour continuer malgré l'avertissement.</param>
protected async Task<bool> ConfirmPlayerSigningAsync(string kind, string confirmText = "Publier quand même")
{
if (Context.PlayerSigning is not { } check) return true;
string? warning;
var wasBusy = IsBusy;
IsBusy = true; // contrôle réseau (quelques secondes au plus) : boutons désactivés
try
{
warning = await check.WarningAsync(kind, Context.Session.PublicKey);
}
catch (Exception ex)
{
// Contrôle indicatif : une erreur imprévue ne bloque pas la publication.
Log.Warn($"Administration : contrôle du trousseau des joueurs impossible ({ex.Message}).");
return true;
}
finally
{
IsBusy = wasBusy;
}
if (warning is null) return true;
Log.Warn($"Administration : avertissement avant publication ({kind}) : {warning.Split('\n')[0]}");
return Context.Dialogs.Confirm("Clé non reconnue par les launchers des joueurs", warning, confirmText);
}
/// <summary>
/// Exécute une action réseau : « occupé » pendant l'action, erreur affichée en cas d'échec,
/// retour à la connexion si la session a expiré. Vrai si l'action a réussi.
@@ -30,6 +30,12 @@ public interface IAdminContext
/// <summary>Clés racine intégrées à l'exe (vérification du trousseau avant sa publication).</summary>
IReadOnlyList<(string KeyId, byte[] PublicKey)> RootKeys { get; }
/// <summary>
/// Contrôle avant publication : la clé de ce PC est-elle acceptée par les launchers des joueurs (trousseau publié) ?
/// Null : pas de contrôle (signatures non vérifiées par ce launcher, ou tests).
/// </summary>
PlayerSigningCheck? PlayerSigning => null;
/// <summary>Pages visibles pour cet administrateur.</summary>
IReadOnlyList<AdminPanelDescriptor> Panels { get; }
@@ -0,0 +1,122 @@
using System.Text.Json;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Trust;
namespace GameLauncher.Modules.Admin;
/// <summary>
/// Avertissement avant une publication signée (catalogue, version d'un jeu) : la clé de ce PC figure-t-elle dans le
/// trousseau publié (celui que téléchargent les launchers des joueurs), avec l'autorisation voulue ?
/// Contrôle côté administration uniquement, sans effet sur le serveur, qui accepte la publication dans tous les cas :
/// c'est le launcher des joueurs qui ignore un contenu signé par une clé absente du trousseau (contentSigning « require »).
/// Le trousseau est relu à chaque contrôle (pas de cache) : une clé ajoutée par un propriétaire est vue aussitôt.
/// </summary>
public sealed class PlayerSigningCheck
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(15);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _keyringUrl;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="download">Téléchargement d'un petit fichier (https, taille limitée, sans cache).</param>
/// <param name="keyringUrl">« keyringUrl » du launcher.json intégré (le même que celui des joueurs).</param>
/// <param name="mode">« contentSigning » du launcher.json intégré.</param>
/// <param name="minKeyringSequence">« keyringMinSequence » : un trousseau plus ancien est refusé par les launchers.</param>
/// <param name="rootKeys">Clés racine intégrées à l'exe (vérification du trousseau, comme chez les joueurs).</param>
public PlayerSigningCheck(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? keyringUrl, ContentSigningMode mode,
long minKeyringSequence, IReadOnlyList<(string KeyId, byte[] PublicKey)> rootKeys)
{
_download = download;
_keyringUrl = keyringUrl;
Mode = mode;
MinKeyringSequence = Math.Max(0, minKeyringSequence);
_rootKeys = rootKeys;
}
public ContentSigningMode Mode { get; }
public long MinKeyringSequence { get; }
/// <summary>
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
/// </summary>
/// <param name="kind"><see cref="ContentKinds.Catalog"/> ou <see cref="ContentKinds.Manifest"/>.</param>
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return null;
var keyId = AdminKeys.ComputeKeyId(publicKey);
var scope = ContentKinds.ScopeOf(kind);
string problem;
if (_keyringUrl is null)
{
problem = "Aucun trousseau n'est configuré dans ce launcher (« keyringUrl ») : les launchers des joueurs ne peuvent vérifier aucune signature.";
}
else
{
KeyringDocument keyring;
try
{
keyring = await DownloadAsync(_keyringUrl, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
problem = $"Le trousseau publié ({_keyringUrl}) est refusé par ce launcher : {ex.Message}.";
return Compose(kind, problem);
}
catch (Exception ex)
{
problem = $"Le trousseau publié ({_keyringUrl}) est injoignable ({ex.Message}) : impossible de vérifier que la clé de ce PC y figure.";
return Compose(kind, problem);
}
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
if (key is null)
problem = $"La clé de ce PC ({keyId}) n'est pas dans le trousseau publié (n° {keyring.Sequence}).";
else if (!key.Scopes.Contains(scope, StringComparer.Ordinal))
problem = $"La clé de ce PC ({keyId}, « {key.Name} ») est dans le trousseau publié (n° {keyring.Sequence}), " +
$"mais sans l'autorisation « {SigningScopes.DisplayName(scope)} ».";
else
return null;
}
return Compose(kind, problem);
}
/// <summary>Trousseau publié, vérifié comme le font les launchers des joueurs (format, clé racine, numéro minimal).</summary>
private async Task<KeyringDocument> DownloadAsync(Uri url, CancellationToken ct)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
var bytes = await _download(url, Keyring.MaxBytes, timeout.Token).ConfigureAwait(false);
var keyring = Keyring.Parse(bytes) ?? throw new InvalidDataException("trousseau vide");
var errors = new List<string>();
if (!Keyring.Validate(keyring, errors)) throw new InvalidDataException(errors[0].TrimEnd('.'));
if (!Keyring.VerifyRoot(keyring, _rootKeys, out var error)) throw new InvalidDataException(error.TrimEnd('.'));
if (keyring.Sequence < MinKeyringSequence)
throw new InvalidDataException($"trousseau n° {keyring.Sequence} plus ancien que le minimum n° {MinKeyringSequence} de ce launcher");
return keyring;
}
private string Compose(string kind, string problem)
{
var catalog = kind == ContentKinds.Catalog;
var consequence = Mode == ContentSigningMode.Require
? catalog
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
"(celui intégré au launcher pour une installation neuve)."
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
"(le jeu déjà installé reste jouable)."
: "Les launchers des joueurs l'accepteront, mais inscriront une alerte de signature dans leur journal (vérification en mode « warn »).";
return problem + "\n\n" + consequence + "\n\n" +
"Le serveur accepte la publication quand même. Pour régler ce point, un propriétaire ajoute la clé de ce PC au trousseau " +
"(page « Signature »), le fait signer avec la clé racine puis le publie : les launchers accepteront alors ce contenu " +
"sans nouvelle publication.";
}
}
+16 -3
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;
@@ -58,6 +59,7 @@ public partial class App : Application, ILauncherRestartHost
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
private PlayerSigningCheck? _playerSigning;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
private Version _version = new(1, 0, 0);
@@ -254,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;
@@ -356,8 +361,13 @@ public partial class App : Application, ILauncherRestartHost
{
var (apiUrl, apiUrlError) = ResolveAdminApiUrl(catalog);
_admin = new AdminWindowService(window, CreateModules(), catalog, LoadDeclaredCatalog(catalog), apiUrl, apiUrlError, version,
ThemeManager.BuiltInThemes.ToList());
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;
@@ -476,8 +486,10 @@ public partial class App : Application, ILauncherRestartHost
Log.Error("Signatures du contenu : aucune clé racine intégrée (Security\\update-signing.pub) : aucun trousseau ne peut être vérifié.");
Log.Info($"Signatures du contenu : mode « {mode.ToString().ToLowerInvariant()} », trousseau {keyringUrl ?? "non configuré"}" +
(minSequence > 0 ? $" (n° {minSequence} au minimum)." : "."));
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUrl is null ? null : new Uri(keyringUrl), roots, mode,
minSequence);
var keyringUri = keyringUrl is null ? null : new Uri(keyringUrl);
// Menu d'administration : avertissement avant de publier avec une clé que ces launchers n'accepteraient pas.
_playerSigning = new PlayerSigningCheck(_http!.GetBytesAsync, keyringUri, mode, minSequence, roots);
return new ContentTrustService(_http!.GetBytesAsync, catalog.DataDirectory, keyringUri, roots, mode, minSequence);
}
/// <summary>
@@ -567,6 +579,7 @@ public partial class App : Application, ILauncherRestartHost
{
new AdminModule(),
new CatalogAdminModule(),
new FeedbackAdminModule(),
new ChangelogAdminModule(),
new PublishAdminModule(),
new FilesAdminModule(),
@@ -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.0</Version>
<Version>3.0.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();
}
@@ -4,6 +4,7 @@ using GameLauncher.ApiClient;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Platform;
using GameLauncher.Views;
@@ -26,6 +27,7 @@ public sealed class AdminWindowService : IDisposable
private readonly string _launcherVersion;
private readonly Uri? _apiUrl;
private readonly string? _apiUrlError;
private readonly PlayerSigningCheck? _playerSigning;
private LauncherApiClient? _client;
private AdminWindow? _window;
@@ -34,8 +36,12 @@ public sealed class AdminWindowService : IDisposable
/// <param name="catalog">Configuration active (dossier des données du launcher).</param>
/// <param name="declared">Jeux déclarés dans launcher.json, avant le catalogue (valeurs par défaut de la page « Jeux »).</param>
/// <param name="themes">Thèmes intégrés au launcher (seuls choix possibles dans le catalogue).</param>
/// <param name="playerSigning">
/// Contrôle du trousseau des joueurs avant une publication signée (null : signatures non vérifiées, pas d'avertissement).
/// </param>
public AdminWindowService(Window owner, ModuleCatalog modules, LauncherCatalog catalog, LauncherCatalog declared, string? apiUrl,
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes)
string? apiUrlError, string launcherVersion, IReadOnlyList<string> themes,
PlayerSigningCheck? playerSigning = null)
{
_owner = owner;
_modules = modules;
@@ -45,6 +51,7 @@ public sealed class AdminWindowService : IDisposable
_launcherVersion = launcherVersion;
_apiUrl = apiUrl is null ? null : new Uri(apiUrl, UriKind.Absolute);
_apiUrlError = apiUrlError;
_playerSigning = playerSigning;
}
public bool IsAvailable => _apiUrl is not null;
@@ -54,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;
}
@@ -85,7 +92,8 @@ public sealed class AdminWindowService : IDisposable
Tpm = new TpmKeyStore(), // clés dans la puce de sécurité : proposées après vérification réelle de la puce
SettingsPath = _settingsPath,
LauncherVersion = _launcherVersion,
RootKeys = LauncherUpdateService.LoadRootKeys()
RootKeys = LauncherUpdateService.LoadRootKeys(),
PlayerSigning = _playerSigning
});
window = new AdminWindow(shell);
if (_owner.IsVisible) window.Owner = _owner;
@@ -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));
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);
}
}
}
+407 -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,255 @@
</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">
<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>
<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">
<TextBlock Text="{Binding Label}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,0,4" />
<TextBox Text="{Binding Value, Mode=OneWay}" IsReadOnly="True" TextWrapping="Wrap" BorderThickness="0"
Background="Transparent" Padding="0" FontSize="13" Foreground="{DynamicResource TextPrimaryBrush}"
CaretBrush="{DynamicResource TextPrimaryBrush}" SelectionBrush="{DynamicResource AccentBrush}"
IsUndoEnabled="False" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<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 +2420,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 +2453,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 +2599,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 +2756,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();
+319
View File
@@ -0,0 +1,319 @@
<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"
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"
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.
-->
<Window.Resources>
<!-- 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" />
<!-- 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" />
</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,68 @@
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
{
private readonly FeedbackFormViewModel _viewModel;
public FeedbackWindow(FeedbackFormViewModel viewModel)
{
InitializeComponent();
_viewModel = viewModel;
DataContext = viewModel;
viewModel.CloseRequested += Close;
viewModel.PropertyChanged += (_, e) =>
{
// Formulaire affiché : le curseur se place dans le titre.
if (e.PropertyName == nameof(FeedbackFormViewModel.IsFilling) && viewModel.IsFilling)
Dispatcher.InvokeAsync(() => TitleBox.Focus(), System.Windows.Threading.DispatcherPriority.Input);
};
SourceInitialized += (_, _) => FitToScreen();
Closed += (_, _) =>
{
viewModel.CloseRequested -= Close;
viewModel.Dispose();
};
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente : au-delà, le formulaire défile.</summary>
private void FitToScreen()
{
const double chrome = 230; // en-tête, boutons, marges et ombre
BodyScroll.MaxHeight = Math.Max(260, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
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)
{
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)
@@ -23,12 +23,15 @@ public sealed class FakeDialogs : IModuleDialogs
public void ShowMessage(string title, string message, ModuleMessageKind kind = ModuleMessageKind.Info) => Messages.Add($"{title} : {message}");
/// <summary>Réponse aux confirmations (vrai par défaut).</summary>
public bool ConfirmAnswer { get; set; } = true;
/// <summary>Réponse selon le titre de la confirmation (null : <see cref="ConfirmAnswer"/>).</summary>
public Func<string, bool?>? AnswerFor { get; set; }
public List<string> Confirmations { get; } = new();
public bool Confirm(string title, string message, string confirmText, string cancelText = "Annuler", bool danger = false)
{
Confirmations.Add($"{title} : {message}");
return ConfirmAnswer;
return AnswerFor?.Invoke(title) ?? ConfirmAnswer;
}
public string? PickOpenFile(string title, string filter, string? initialDirectory) => NextOpenFile;
public string? PickSaveFile(string title, string filter, string defaultFileName, string? initialDirectory) => NextSaveFile;
@@ -85,9 +88,11 @@ public static class AdminTestKit
public static AdminEnvironment Environment(TestApi api, string folder, FakeDialogs dialogs, ModuleCatalog? modules = null,
IAdminTpm? tpm = null, IReadOnlyList<LauncherConfig>? games = null,
IReadOnlyList<string>? themes = null,
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null) => new()
IReadOnlyList<(string KeyId, byte[] PublicKey)>? rootKeys = null,
PlayerSigningCheck? playerSigning = null) => new()
{
RootKeys = rootKeys ?? Array.Empty<(string, byte[])>(),
PlayerSigning = playerSigning,
Tpm = tpm,
Client = api.CreateClient(),
Modules = modules ?? new ModuleCatalog(new ILauncherModule[] { new AdminModule() }),
@@ -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,193 @@
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<string> SendAsync(FeedbackSubmission submission, CancellationToken ct) => throw new InvalidOperationException();
}
// ------------------------------------------------------------------ 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,240 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Trust;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Modules.Admin.Publishing;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Publishing;
using GameLauncher.Tests.Server;
using GameLauncher.Tests.Signing;
namespace GameLauncher.Tests.Admin;
/// <summary>
/// Avertissement avant une publication signée par une clé que les launchers des joueurs n'accepteront pas
/// (absente du trousseau publié, ou sans l'autorisation voulue). Le serveur, lui, accepte la publication.
/// </summary>
public class PlayerSigningCheckTests
{
private const string WarningTitle = "Clé non reconnue par les launchers des joueurs";
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
/// <summary>Trousseau servi (modifiable entre deux contrôles) et nombre de téléchargements.</summary>
private sealed class KeyringServer
{
public byte[] Bytes { get; set; } = Array.Empty<byte>();
public Exception? Failure { get; set; }
public int Downloads { get; private set; }
public void Serve(KeyringDocument keyring) => Bytes = Encoding.UTF8.GetBytes(Keyring.Serialize(keyring));
public Task<byte[]> DownloadAsync(Uri url, long maxBytes, CancellationToken ct)
{
Downloads++;
Assert.Equal(KeyringUrl, url);
Assert.Equal(Keyring.MaxBytes, maxBytes);
return Failure is null ? Task.FromResult(Bytes) : Task.FromException<byte[]>(Failure);
}
}
private static KeyringKey EntryFor(byte[] publicKey, string name, params string[] scopes) => new()
{
KeyId = GameLauncher.Contracts.Security.AdminKeys.ComputeKeyId(publicKey),
PublicKey = Convert.ToBase64String(publicKey),
Name = name,
Scopes = scopes.Length == 0 ? SigningScopes.All.ToList() : scopes.ToList()
};
[Fact]
public async Task The_published_keyring_decides_whether_a_warning_is_shown()
{
using var root = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 2, new[] { SigningKit.Root(root) });
// Clé présente, toutes autorisations : aucun avertissement.
server.Serve(SigningKit.Keyring(root, 3, SigningKit.Entry(mine, "PC de Nicolas"), SigningKit.Entry(other, "PC de Paul")));
Assert.Null(await check.WarningAsync(ContentKinds.Catalog, publicKey));
Assert.Null(await check.WarningAsync(ContentKinds.Manifest, publicKey));
// Clé absente : catalogue ignoré par les launchers (« require »), le serveur l'accepte quand même.
server.Serve(SigningKit.Keyring(root, 4, SigningKit.Entry(other, "PC de Paul")));
var warning = await check.WarningAsync(ContentKinds.Catalog, publicKey);
Assert.NotNull(warning);
Assert.Contains($"La clé de ce PC ({SigningKit.KeyId(mine)}) n'est pas dans le trousseau publié (n° 4)", warning);
Assert.Contains("ignoreront ce catalogue", warning);
Assert.Contains("Le serveur accepte la publication quand même", warning);
Assert.Contains("page « Signature »", warning);
// Clé présente sans l'autorisation des versions : seul le manifest est concerné.
server.Serve(SigningKit.Keyring(root, 5, SigningKit.Entry(mine, "PC de Nicolas", SigningScopes.Catalog)));
Assert.Null(await check.WarningAsync(ContentKinds.Catalog, publicKey));
warning = await check.WarningAsync(ContentKinds.Manifest, publicKey);
Assert.Contains("est dans le trousseau publié (n° 5), mais sans l'autorisation", warning);
Assert.Contains($"« {SigningScopes.DisplayName(SigningScopes.Releases)} »", warning);
Assert.Contains("refuseront cette version", warning);
// Trousseau relu à chaque contrôle : la clé ajoutée par un propriétaire est vue aussitôt.
server.Serve(SigningKit.Keyring(root, 6, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Null(await check.WarningAsync(ContentKinds.Manifest, publicKey));
Assert.Equal(6, server.Downloads);
}
[Fact]
public async Task An_unverifiable_keyring_is_reported_like_the_players_launchers_would()
{
using var root = SigningKit.NewKey();
using var forged = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 2, new[] { SigningKit.Root(root) });
// Signé par une autre clé racine que celle intégrée à l'exe.
server.Serve(SigningKit.Keyring(forged, 3, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Contains("est refusé par ce launcher", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Plus ancien que le numéro minimal du launcher.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(mine, "PC de Nicolas")));
Assert.Contains("plus ancien que le minimum n° 2", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Illisible.
server.Bytes = Encoding.UTF8.GetBytes("{ pas du json");
Assert.Contains("est refusé par ce launcher", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Injoignable : l'avertissement le dit (vérification impossible), sans bloquer.
server.Failure = new HttpRequestException("délai dépassé");
Assert.Contains("est injoignable (délai dépassé)", await check.WarningAsync(ContentKinds.Catalog, publicKey));
// Aucun trousseau configuré alors que les signatures sont vérifiées.
var unconfigured = new PlayerSigningCheck(server.DownloadAsync, null, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
Assert.Contains("Aucun trousseau n'est configuré", await unconfigured.WarningAsync(ContentKinds.Catalog, publicKey));
}
[Fact]
public async Task Warn_mode_only_mentions_the_journal_and_off_checks_nothing()
{
using var root = SigningKit.NewKey();
using var mine = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var publicKey = mine.ExportSubjectPublicKeyInfo();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(other, "PC de Paul")));
var warn = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Warn, 0, new[] { SigningKit.Root(root) });
var warning = await warn.WarningAsync(ContentKinds.Catalog, publicKey);
Assert.Contains("l'accepteront, mais inscriront une alerte", warning);
Assert.DoesNotContain("ignoreront", warning);
var off = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Off, 0, new[] { SigningKit.Root(root) });
Assert.Null(await off.WarningAsync(ContentKinds.Catalog, publicKey));
Assert.Equal(1, server.Downloads); // mode « off » : pas de téléchargement
}
[Fact]
public async Task Games_page_warns_before_publishing_and_the_server_still_accepts()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-catalog-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var publicFile = Path.Combine(directory, "catalog.json");
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Catalog:PublicFile"] = publicFile });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
using var root = SigningKit.NewKey();
using var other = SigningKit.NewKey();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(other, "PC de Paul")));
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
var dialogs = new FakeDialogs();
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new CatalogAdminModule() });
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, modules, playerSigning: check));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(GamesPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<GamesPanelViewModel>(workspace.SelectedPanel!.ViewModel);
page.Games.Single().Tagline = "Survivez à l'école.";
// Avertissement refusé : rien n'est signé ni publié.
dialogs.AnswerFor = title => title == WarningTitle ? false : null;
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith(WarningTitle) && c.Contains(signer.KeyId) && c.Contains("ignoreront ce catalogue"));
Assert.False(File.Exists(publicFile));
Assert.True(page.HasChanges);
Assert.False(page.IsBusy);
// « Publier quand même » : le serveur accepte (comportement inchangé).
dialogs.AnswerFor = null;
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains(dialogs.Confirmations, c => c.StartsWith(WarningTitle));
Assert.Contains("révision 1", page.StatusMessage);
Assert.True(File.Exists(publicFile));
// Clé ajoutée au trousseau : plus d'avertissement.
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(other, "PC de Paul"), EntryFor(signer.PublicKey, "PC de Nicolas")));
page.Games.Single().Tagline = "Survivez au collège.";
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.DoesNotContain(dialogs.Confirmations, c => c.StartsWith(WarningTitle));
Assert.Contains("révision 2", page.StatusMessage);
}
[Fact]
public async Task Publish_page_warns_before_sending_a_release()
{
var manifest = ChangelogModuleTests.PublishedManifest();
var release = PublishingTests.ReleaseFolder.Create(version: "1.2", smallFiles: 2, bigFileBytes: 4096, archiveBytes: 2000);
await using var api = await TestApi.StartAsync(new Dictionary<string, string?> { ["LauncherApi:Games:EZR:ManifestFile"] = manifest });
using var folder = new TempFolder();
var (_, signer) = AdminTestKit.CreateKeyFile(folder.Path);
using var _ = signer;
AdminTestKit.RegisterOwner(api, signer);
using var root = SigningKit.NewKey();
var server = new KeyringServer();
server.Serve(SigningKit.Keyring(root, 1, EntryFor(signer.PublicKey, "PC de Nicolas", SigningScopes.Catalog)));
var check = new PlayerSigningCheck(server.DownloadAsync, KeyringUrl, ContentSigningMode.Require, 0, new[] { SigningKit.Root(root) });
var dialogs = new FakeDialogs();
var modules = new ModuleCatalog(new ILauncherModule[] { new AdminModule(), new PublishAdminModule() });
await using var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder.Path, dialogs, modules, playerSigning: check));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(PublishPanelViewModel.PanelId);
await workspace.Activation;
var page = Assert.IsType<PublishPanelViewModel>(workspace.SelectedPanel!.ViewModel);
dialogs.NextOpenFile = release.ManifestPath;
await page.ChooseCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
// Clé sans l'autorisation des versions : avertissement refusé, rien n'est envoyé (ni la confirmation habituelle).
dialogs.AnswerFor = title => title == WarningTitle ? false : null;
await page.PublishCommand.ExecuteAsync();
var asked = Assert.Single(dialogs.Confirmations);
Assert.StartsWith(WarningTitle, asked);
Assert.Contains("refuseront cette version", asked);
Assert.Empty(Directory.GetDirectories(Path.GetDirectoryName(manifest)!, ".publish-*"));
Assert.False(page.IsBusy);
// « Publier quand même » puis confirmation habituelle : version publiée, le serveur l'accepte.
dialogs.AnswerFor = null;
dialogs.Confirmations.Clear();
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Equal(2, dialogs.Confirmations.Count);
Assert.StartsWith("Publier la version 1.2", dialogs.Confirmations[1]);
Assert.Contains("Version 1.2 de Exovival Zombie Reborn (EZR) publiée", page.StatusMessage);
}
}
@@ -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,334 @@
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.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));
}
}
+111 -96
View File
@@ -1,129 +1,144 @@
# Launcher 2.4.0
# Launcher 3.0.0
LAUNCHER - NOTES DE MISE À JOUR
Menu d'administration : groupes, permissions et images - Mise à jour du 7 novembre 2026
Bouton « Aide » et demandes des joueurs - Mise à jour du 9 novembre 2026
==================================================================
Version consacrée au menu d'administration : les rôles fixes deviennent des groupes modifiables avec leurs permissions, l'icône et l'image de fond d'un jeu s'envoient directement depuis le launcher (plus besoin de client FTP), et les pages en deux colonnes défilent séparément.
Un nouveau bouton « Aide » permet d'envoyer à l'équipe, sans compte, un avis, une demande d'évolution, un bug ou autre chose, pour le jeu sélectionné. Chaque type de demande a ses propres champs, et une référence est remise à l'envoi.
Pour les joueurs, rien ne change visiblement : le jeu, les mises à jour et le launcher fonctionnent comme avant. Les images du catalogue envoyées depuis le menu sont désormais vérifiées au téléchargement.
Dans le menu d'administration, la nouvelle page « Demandes des joueurs » range les demandes par jeu. Les demandes sont anonymes : aucun compte, aucune adresse IP enregistrée. Le serveur est à redéployer et à configurer avant de publier cette version.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : page « Groupes et permissions » : créer, modifier et
supprimer des groupes d'administrateurs et choisir leurs
permissions.
- NOUVEAU : permissions personnalisées (« module.action »), en
prévision de modules futurs.
- NOUVEAU : page « Jeux » : bouton « Parcourir... » pour envoyer
l'icône et l'image de fond sans client FTP, avec aperçu.
- SÉCURITÉ : les images envoyées depuis le menu sont vérifiées
par les launchers des joueurs (empreinte SHA-256).
- AMÉLIORÉ : pages « Jeux », « Notes de version » et « Groupes et
permissions » : la liste de gauche et la fiche de droite
défilent séparément.
- CORRIGÉ : « Fichiers du serveur » : l'ascenseur ne remonte plus
à chaque changement de dossier.
- MODIFIÉ : « Administrateurs » : « rôle » devient « groupe ».
- SERVEUR : nouvelles routes : à redéployer (voir plus bas).
- QUALITÉ : 21 nouveaux tests automatisés (276 au total).
- NOUVEAU : bouton « Aide » dans la barre de titre : avis, demande
d'évolution, bug ou autre, pour le jeu sélectionné.
- NOUVEAU : formulaires à champs prédéfinis selon le type, contrôlés
avant l'envoi ; référence FB-XXXX-XXXX remise au joueur.
- NOUVEAU : page d'administration « Demandes des joueurs » et carte
sur le tableau de bord.
- NOUVEAU : purge automatique des demandes et du journal d'audit
(12 mois).
- SÉCURITÉ : défi anti-spam, limites par adresse, plafonds, contrôle
complet du contenu ; demandes anonymes (aucune adresse IP
enregistrée).
- SERVEUR : à redéployer et à configurer (réception désactivée par
défaut).
- QUALITÉ : 30 nouveaux tests automatisés (312 au total).
------------------------------------------------------------------
GROUPES ET PERMISSIONS
BOUTON « AIDE »
------------------------------------------------------------------
- NOUVEAU : les rôles Propriétaire, Administrateur et Éditeur
deviennent des groupes, créés automatiquement avec les mêmes
droits qu'avant : rien ne change pour les comptes existants.
- NOUVEAU : page « Groupes et permissions » (réservée à la gestion
des administrateurs) :
* créer un groupe (nom, description, permissions à cocher) ;
* renommer un groupe, changer ses permissions ;
* supprimer un groupe sans membre ni invitation en cours ;
* créer et supprimer des permissions personnalisées ;
* point orange sur un groupe modifié non enregistré.
- Les changements s'appliquent immédiatement, y compris aux
sessions déjà ouvertes.
- SÉCURITÉ :
* le groupe Propriétaire a toujours toutes les permissions : il
ne peut être ni modifié ni supprimé ;
* on ne peut accorder que des permissions que l'on a soi-même ;
* on ne modifie pas les permissions de son propre groupe ;
* seul un propriétaire donne ou retire le groupe Propriétaire,
ou gère un autre propriétaire ;
* les permissions intégrées (vérifiées par le launcher et le
serveur) ne peuvent pas être supprimées ;
* deux administrateurs qui modifient le même groupe en même
temps : la seconde modification est refusée, rien n'est
écrasé ;
* chaque action est signée par la clé de l'administrateur et
inscrite au journal d'audit.
- MODIFIÉ : page « Administrateurs » : le groupe remplace le rôle ;
seuls les groupes que vous pouvez attribuer sont proposés.
- MODIFIÉ : page « Signature » : les autorisations proposées pour
le trousseau suivent les permissions du groupe de chaque
administrateur.
- Bulle avec un point d'interrogation, dans la barre de titre, à
gauche du bouton « Site officiel ».
- Le même bouton pour tous les jeux : la demande est rattachée au
jeu sélectionné dans la liste de gauche, affiché en en-tête et non
modifiable pendant la saisie.
- Trois étapes : choix du type, formulaire, référence à copier.
- Champs prédéfinis (* obligatoire) :
* Avis : note de 1 à 5 étoiles*, sujet*, ce que vous aimez, ce qui
pourrait être amélioré, recommanderiez-vous le jeu ? ;
* Demande d'évolution : domaine*, description de l'idée*, problème
résolu, importance ;
* Bug : catégorie*, gravité*, fréquence*, étapes pour reproduire*,
résultat attendu, résultat obtenu* ;
* Autre : message*.
- Informations techniques facultatives (versions du launcher et du
jeu, Windows, langue) : contenu exact affiché avant l'envoi, case
cochée par défaut pour un bug seulement.
- Saisie conservée en cas d'échec (réseau, serveur occupé) : il
suffit de cliquer de nouveau sur « Envoyer ».
- Échap ne ferme pas la fenêtre pendant la saisie.
- Le launcher ne contacte le serveur qu'au moment de l'envoi.
- Pas de réponse possible pour l'instant : un futur module « comptes
joueurs » permettra d'échanger avec l'équipe.
------------------------------------------------------------------
IMAGES DES JEUX (ICÔNE, FOND)
PAGE « DEMANDES DES JOUEURS » (ADMINISTRATION)
------------------------------------------------------------------
- NOUVEAU : page « Jeux » : bouton « Parcourir... » à côté de
l'icône et de l'image de fond :
* choix de l'image dans l'explorateur Windows ;
* contrôle sur le PC, puis envoi au serveur ;
* adresse renseignée automatiquement et aperçu de l'image ;
* bouton pour vider le champ.
- L'image est appliquée aux joueurs après « Publier », au
prochain démarrage de leur launcher.
- Formats : PNG ou JPEG, 4 Mo au plus ; icône de 32 à 2048 px,
image de fond de 640 x 360 à 7680 x 4320 px.
- SÉCURITÉ :
* image reconnue à son contenu, pas à son extension ;
* fichier refusé s'il est incomplet ou suivi de données
étrangères à l'image ;
* chaque image est enregistrée sous son empreinte SHA-256 et
vérifiée par les launchers des joueurs au téléchargement ;
* envoi signé par la clé de l'administrateur, inscrit au
journal d'audit.
- AMÉLIORÉ : l'aide sous le champ « Icône » indique où elle
s'affiche (liste des jeux à gauche, visible seulement avec
plusieurs jeux).
- Choix du jeu : une tuile par jeu avec le nombre de nouvelles
demandes ; les demandes d'un jeu ne se mélangent jamais à celles
d'un autre.
- Filtres : type, statut (« À traiter » par défaut), recherche dans
le titre ou la référence ; 100 demandes par page.
- Fiche : tous les champs en texte brut, informations techniques,
référence copiable.
- Traitement : statut (Nouvelle, En cours, Traitée, Rejetée,
Doublon), note interne visible des seuls administrateurs,
suppression après confirmation.
- Tableau de bord : carte « Demandes des joueurs » (nouvelles
demandes par jeu).
- Permissions : feedback.read (lecture) et feedback.manage
(traitement) ; Propriétaire et Administrateur, pas l'Éditeur.
- Serveur déjà installé : seul le groupe Propriétaire reçoit ces
permissions d'office ; les ajouter au groupe « Administrateur »
depuis la page « Groupes et permissions ».
------------------------------------------------------------------
NAVIGATION DANS LE MENU D'ADMINISTRATION
CONSERVATION ET PURGE AUTOMATIQUE
------------------------------------------------------------------
- AMÉLIORÉ : pages « Jeux », « Notes de version » et « Groupes et
permissions » : chaque colonne a son propre ascenseur ; la
liste de gauche reste visible quand on descend dans la fiche.
- AMÉLIORÉ : les bandeaux (erreur, résultat) et le choix du jeu
restent en haut de la page.
- CORRIGÉ : « Fichiers du serveur » : se déplacer dans les
dossiers (fil d'Ariane, double-clic, dossier parent) ne fait
plus remonter l'ascenseur.
- Au démarrage du serveur puis toutes les 24 heures.
- Demande close (traitée, rejetée, doublon) : supprimée 12 mois
après sa clôture ; toute demande : 24 mois au plus.
- Journal d'audit : fichiers de plus de 12 mois supprimés (réglage
Audit:RetentionMonths) ; corrige l'écart RGPD relevé.
------------------------------------------------------------------
SERVEUR (LAUNCHER API) : À REDÉPLOYER
SÉCURITÉ ET VIE PRIVÉE
------------------------------------------------------------------
- Avant la mise à jour : copier admins.json (il passe au format 2,
qu'une ancienne version du serveur refuse).
- nginx : ajouter le bloc d'envoi des images (nginx-api.conf).
- Vérifier que le dossier catalog/images existe.
- Détails : server/deploy/LISEZMOI.md, étape 3 septies.
- Première route d'envoi ouverte à tous, désactivée par défaut ; le
serveur contrôle tout, avec les mêmes règles que le launcher.
- Défi anti-spam (calcul de quelques fractions de seconde), sans
service tiers ni captcha, à usage unique.
- Limites : 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 par jeu, 100 Mo au total.
- Caractères invisibles ou spéciaux refusés ; affichage en texte
brut dans le menu.
- Aucune adresse IP enregistrée avec une demande ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux ;
aucun identifiant du PC envoyé.
- Consultations, changements de statut et suppressions inscrits au
journal d'audit, sans le contenu des demandes ; statut, note et
suppression en requêtes signées.
- Stockage : un fichier par demande dans les données privées du
serveur (SQLite évalué et écarté pour ce module).
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer AVANT de publier le launcher : server/deploy/
LISEZMOI.md, étape 8, puis étape 3 octies.
- Activer la réception : "Feedback": { "Enabled": true,
"Games": [ "EZR" ] } dans appsettings.Production.json.
- nginx : zone feedback_par_ip et deux blocs
« location = /api/v1/feedback… » (nginx-api.conf).
- La purge du journal d'audit s'applique dès le premier démarrage.
- Sans redéploiement, le bouton « Aide » indique que l'envoi des
demandes n'est pas disponible.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Outil de captures d'écran : la dernière capture d'une page
longue montre toujours le bas de la page ; nouvelles routes
simulées.
- AdminKeyTool : affiche le nom des groupes.
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md et
server/deploy/LISEZMOI.md mis à jour.
- Nouveau projet GameLauncher.Modules.Feedback (formulaire du joueur,
sans WPF) ; fenêtre Views/FeedbackWindow.xaml.
- Formulaires décrits une seule fois (Contracts/Feedback) : le
launcher les génère, le serveur valide avec, le menu affiche les
libellés.
- Serveur : module Feedback, FeedbackGuard (défi, limites),
FeedbackStore (stockage), RetentionService (purges).
- Menu d'administration : FeedbackAdminModule (page « Demandes des
joueurs »).
- Tests : FeedbackRulesTests.cs, 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.