Compare commits

...
12 Commits
Author SHA1 Message Date
nicolas 97df0ba721 Merge pull request 'Dev/3.2.0' (#135) from dev/3.2.0 into master
Reviewed-on: #135
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 09:25:36 +01:00
nicolas f0f403ce66 Version 3.2.0 et notes de mise à jour 2026-11-10 09:16:42 +01:00
nicolas 977e2ae892 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:16:32 +01:00
nicolas 1913fcdb01 Docs : formulaires des demandes modifiables (§ 11 sexies, README) 2026-11-10 09:16:06 +01:00
nicolas f25051c627 ui-snapshot : page « Formulaires des demandes » et formulaires publiés 2026-11-10 09:15:32 +01:00
nicolas 82ad4ec500 Tests : formulaires des demandes (contrats, serveur, menu, trousseau) 2026-11-10 09:15:14 +01:00
nicolas a4ef606ff7 Admin : page « Formulaires des demandes » et demandes affichées avec leur formulaire 2026-11-10 09:14:52 +01:00
nicolas fcb27d40b1 Launcher : fenêtre « Aide » avec formulaires publiés, nouveaux champs, icônes importées, captures par type 2026-11-10 09:13:34 +01:00
nicolas 45140ba78a Déploiement : blocs nginx des formulaires (lecture publique, publication 300 Ko) 2026-11-10 09:12:38 +01:00
nicolas 5457e66144 Serveur : publication signée des formulaires, validation des demandes par révision 2026-11-10 09:12:18 +01:00
nicolas 05c5e24327 Signature : type de contenu feedback-forms, autorisation « forms » du trousseau, permission feedback.forms 2026-11-10 09:11:31 +01:00
nicolas d920702353 Contrats : formulaires des demandes signés, catégories dynamiques et icônes .ico 2026-11-10 09:10:37 +01:00
51 changed files with 4464 additions and 278 deletions

No files matched your search

+6
View File
@@ -529,6 +529,12 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs** (voir plus bas).
son contenu, réencodée en PNG/JPEG **sans métadonnées** (ni GPS, ni appareil), revérifiée par le serveur (PNG/JPEG
seulement, 4 Mo). En cas d'échec (réseau, serveur), la saisie est conservée. Bouton absent
sans `apiUrl` ; réception à activer sur le serveur (`server/deploy/LISEZMOI.md`, étape 3 octies).
* **Page « Formulaires des demandes »** (permission `feedback.forms`) : types de demande (1 à 8 : ajout, suppression,
ordre d'affichage, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées ou non) et
leurs champs (zone de texte, texte court, liste déroulante, cases à cocher, case oui/non, étoiles), modifiables ou
masqués, avec un aperçu identique à la fenêtre des joueurs et un contrôle en direct ; communs à tous les jeux, **signés** (autorisation « formulaires » du trousseau) et vérifiés par le launcher
à l'ouverture de la fenêtre « Aide », sans aucun lien autorisé dans les libellés (`server/deploy/LISEZMOI.md`,
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi.
* **Page « Demandes des joueurs »** (permission `feedback.read` ; traitement : `feedback.manage`) : choix du jeu
(nombre de nouvelles demandes sur chaque tuile), filtres (type, statut, recherche), fiche complète, statut (Nouvelle,
En cours, Traitée, Rejetée, Doublon), note interne, suppression, captures d'écran (miniature, agrandissement). Une
+84
View File
@@ -782,6 +782,90 @@ recherche, liste et fiche en deux colonnes (texte brut, sélectionnable, jamais
(`feedback.manage`), suppression confirmée ; bandeaux « réception désactivée » et « plafond atteint ». Tableau de bord :
carte « Demandes des joueurs » (nouvelles demandes par jeu).
### 11 sexies. Formulaires des demandes modifiables (7e ✅)
**Besoin** : gérer depuis le menu les types de demande (**1 à 8** : ajout, suppression, **ordre d'affichage**, nom,
description, icône, exemple de titre, informations techniques cochées ou non, **captures d'écran autorisées ou non**,
type masqué) et leurs champs (ajout, retrait, ordre, libellé, aide, obligatoire, longueur, choix), **communs à tous
les jeux**. Les types de champs proposés restent ceux ci-dessous.
**Icônes** (`Contracts/Feedback/FeedbackIcons`) : l'une des **quatre icônes d'origine** (`Star`, `Lightbulb`, `Bug`,
`Forum`), ou une icône **importée depuis un fichier .ico** (extension et en-tête vérifiés ; 1 Mo au plus) qui doit
contenir une image de **64 × 64 pixels** (PNG intégré, revérifié, ou bitmap 24/32 bits avec son masque ; 256 couleurs
et moins refusés ; les autres tailles sont ignorées). Lecture sans bibliothèque, puis réécriture en **PNG 64 × 64
neuf, sans métadonnées**, intégré en base64 aux formulaires (`FeedbackForm.CustomIcon`) : il est donc **couvert par la
signature** ; serveur et launchers le revérifient (`CheckCustom` : PNG valide, exactement 64 × 64, 20 Ko au plus,
déjà nettoyé).
**Captures d'écran par type** (`FeedbackForm.AllowAttachments`, vrai par défaut) : faux = section masquée dans la
fenêtre « Aide », aucune capture envoyée, et le serveur refuse une demande qui en annonce (`FeedbackRules.Validate`).
Mêmes règles qu'avant sinon (3 au plus, conversion, contrôles).
**Types de champs** (`FeedbackFieldKind`, écrits en toutes lettres) : `Multiline` (zone de texte, 20 à 2 000
caractères), `Text` (texte court, 1 à 200), `Choice` (liste déroulante, choix unique), `Checkboxes` (cases à cocher :
identifiants séparés par des virgules, remis dans l'ordre du formulaire), `YesNo` (`yes`/`no` ; obligatoire = doit
être cochée), `Rating` (3, 5 ou 10 étoiles). `ShowInList` : la valeur d'un champ (liste, note ou oui/non) sert
d'aperçu dans la liste du menu (remplace la règle figée « note » / « gravité »).
**Document** (`Contracts/Feedback/FeedbackFormsDocument`) : `feedback-forms.json` = format, version, **révision**,
formulaires, signature ; révision 0 = formulaires d'origine (`FeedbackForms`, jamais publiés). Règles
(`FeedbackFormRules.Validate`, communes au menu, au serveur et au launcher) : 1 à 8 types (identifiants
`[a-z][a-z0-9_]{0,31}` uniques ; ordre du document = ordre d'affichage), au moins un proposé ; 15 champs au plus ;
identifiants de champ uniques ; 2 à 20 choix ; textes déjà propres (une ligne ; aide : 4 lignes, 200 caractères), sans
caractère invisible ni **aucun lien** (`FeedbackLinks`) ; icône d'origine parmi les quatre, icône importée contrôlée ;
**256 Ko** au plus (icônes comprises : route `PUT v1/feedback/forms` à 264 Ko, bloc nginx dédié). Condensé signé : format, version, révision et formulaires (la date de publication n'en fait
pas partie).
**Signature** : nouveau type de contenu `feedback-forms` (identifiant signé `feedback-forms`) et nouvelle autorisation
du trousseau **`forms`** (permission `feedback.forms`, Propriétaire et Administrateur par défaut, pas l'Éditeur).
Une autorisation inconnue mais bien formée est désormais **acceptée** dans le trousseau (sans effet : `Verify` exige
l'autorisation exacte), pour que les prochaines n'invalident plus le trousseau des launchers ; les launchers 3.1 et
avant refusent encore un trousseau contenant `forms` et gardent le précédent (publier la 3.2 avant de re-signer).
| Route | Accès | Signée | Rôle |
|---|---|:-:|---|
| `GET v1/feedback/forms` | administrateur connecté | | Formulaires en vigueur, publication possible, adresse publique, signataire |
| `PUT v1/feedback/forms` | `feedback.forms` | ✔ | Publication : `{ baseRevision, document }` signé par la clé de la session, révision `baseRevision + 1` (409 sinon), audit `feedback.forms` (changements par type) |
**Serveur** (`Storage/FeedbackFormsStore`) : référence privée `feedback-forms.json` (+ `.bak`, historique de 20
révisions), copie **publique** à côté du trousseau (`FeedbackFormsPublicFileFullPath`, 644, atomique ; public d'abord).
Formulaires signés obligatoires (pas de mode « non signé »). SQLite écarté : un seul petit document, rarement modifié,
publié en fichier de toute façon.
**Validation des demandes** : `FeedbackSubmission.FormRevision`. Révision 0 (launchers 3.0 / 3.1, ou formulaires
publiés illisibles chez le joueur) : formulaires d'origine, toujours acceptés sauf type masqué depuis. Révision en
vigueur : son formulaire, **conservé avec la demande** (`FeedbackRecord.Form`, `FeedbackDetail.Form`) pour l'afficher
avec ses libellés d'alors. Autre révision : **409 `feedback_form_changed`** (avant le comptage des limites par
adresse).
**Launcher (joueur)** : `FeedbackFormsService` (`Modules.Feedback`) lit `feedback-forms.json` (adresse déduite du
trousseau, sinon du catalogue) **à l'ouverture de la fenêtre « Aide » seulement**, 256 Ko au plus (icônes importées comprises), 8 s au plus ;
règles contrôlées même si signé ; signature vérifiée par `ContentTrustService.VerifyFeedbackFormsAsync` selon
`contentSigning` (clé autorisée `forms`, date de signature non antérieure) ; révision inférieure à la copie gardée
refusée ; copie gardée dans `feedback\forms.json` et revérifiée à la lecture. Rien de valide : formulaires d'origine.
Fenêtre : « Chargement des formulaires… », types masqués absents, nouveaux champs (texte court, cases à cocher
arrondies, case oui/non, 3 à 10 étoiles), aide affichée sous les champs sans zone de saisie. Sur 409 : formulaires
relus, type reconstruit, saisie des champs de même identifiant et de même type reprise, message au joueur ; type masqué
depuis : retour au choix du type.
**Menu d'administration** : la fiche d'une demande utilise le formulaire conservé avec elle (libellés, nom du type).
Page **« Formulaires des demandes »** (`Feedback/FeedbackFormsPanelViewModel`, `FeedbackFormEditors`, permission
`feedback.forms`, ordre 16) : trois colonnes, types de demande (proposé / masqué, nouveau / modifié ; « Ajouter un
type de demande », 8 au plus, identifiant généré `type_1`… jamais celui d'un type publié ou d'origine, avec un champ
« Message ») ; réglages du type (monter, descendre, supprimer avec confirmation — les demandes reçues restent
consultables ; nom ; icône d'origine ou « Importer un fichier .ico… » / « Retirer l'icône importée » ; description,
exemple de titre, informations techniques cochées par défaut, captures d'écran autorisées, masqué) et champs repliables
(monter, descendre, retirer ; libellé, aide, obligatoire, aperçu dans la liste, longueur, nombre d'étoiles, choix
ordonnés) ; « Ajouter un champ » par type, identifiant généré et fixe (`texte_1`, `cases_2`…, jamais un identifiant
publié) ; **aperçu** rendu avec les modèles de la fenêtre « Aide » (`Themes/FeedbackForms.xaml`, partagé). Contrôle
en direct avec `FeedbackFormRules` (publication bloquée tant qu'il reste une erreur) ; avertissements sans refus :
libellé qui semble demander une donnée personnelle (`PersonalDataWords` : e-mail, mot de passe, téléphone…), type sans
champ. « Publier » : confirmation (types masqués rappelés), contrôle du trousseau publié (`PlayerSigningCheck` :
autorisation « formulaires »), signature par la clé de ce PC, requête signée, révision attendue (409 : bandeau
« Recharger », rien n'est écrasé). « Annuler les modifications », « Revenir à l'origine » (formulaires d'origine dans
l'éditeur, à publier ensuite), « Recharger ». Libellé d'audit : « Formulaires des demandes publiés ».
## 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 :
@@ -239,6 +239,15 @@ public sealed class LauncherApiOptions
/// <summary>Adresse publique du trousseau, calculée par <see cref="Validate"/>.</summary>
public string KeyringPublicUrl { get; private set; } = "";
/// <summary>
/// Fichier public des formulaires des demandes (feedback-forms.json, à côté du trousseau ; null : publication
/// désactivée), calculé par <see cref="Validate"/>. Les launchers le lisent à la même adresse que le trousseau.
/// </summary>
public string? FeedbackFormsPublicFileFullPath { get; private set; }
/// <summary>Adresse publique des formulaires des demandes, calculée par <see cref="Validate"/>.</summary>
public string FeedbackFormsPublicUrl { get; private set; } = "";
/// <summary>Racine du site public pour la page « Fichiers du serveur » (null : page désactivée), calculée par <see cref="Validate"/>.</summary>
public string? FilesRootFullPath { get; private set; }
@@ -388,8 +397,17 @@ public sealed class LauncherApiOptions
{
throw new InvalidOperationException($"{Section}:Keyring:PublicUrl doit être une adresse https://.");
}
// Formulaires des demandes : toujours à côté du trousseau (les launchers en déduisent l'adresse).
FeedbackFormsPublicFileFullPath = KeyringPublicFileFullPath is { } keyringFile
? Path.Combine(Path.GetDirectoryName(keyringFile)!, FeedbackFormsFileName)
: null;
FeedbackFormsPublicUrl = new Uri(new Uri(KeyringPublicUrl), FeedbackFormsFileName).AbsoluteUri;
}
/// <summary>Nom du fichier public des formulaires (à côté de keyring.json).</summary>
public const string FeedbackFormsFileName = "feedback-forms.json";
private void ValidateGames()
{
var files = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
@@ -4,6 +4,7 @@ using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Hosting;
using GameLauncher.Server.Security;
using GameLauncher.Server.Storage;
@@ -20,6 +21,10 @@ namespace GameLauncher.Server.Modules.Feedback;
/// identifiant du PC ne sont enregistrés avec la demande, ni dans l'audit de son envoi.
/// • Administrateurs : lecture (feedback.read, consultations inscrites au journal), statut et note interne,
/// suppression (feedback.manage, requêtes signées). Pas de réponse au joueur (module futur « comptes »).
/// • Formulaires (communs à tous les jeux, <see cref="FeedbackFormsStore"/>) : lecture par tout administrateur (contenu
/// public), publication par feedback.forms, requête signée ET formulaires signés par la clé de la session
/// (autorisation « forms » du trousseau, vérifiée par les launchers). Chaque demande est validée avec le formulaire
/// de la révision qu'elle annonce (0 : formulaires d'origine) ; une révision dépassée répond 409.
/// </summary>
public sealed class FeedbackModule : IApiModule
{
@@ -28,6 +33,7 @@ public sealed class FeedbackModule : IApiModule
public void ConfigureServices(IServiceCollection services, LauncherApiOptions options)
{
services.AddSingleton<FeedbackStore>();
services.AddSingleton<FeedbackFormsStore>();
services.AddSingleton<FeedbackGuard>();
services.AddSingleton<FeedbackConsultations>();
}
@@ -41,6 +47,9 @@ public sealed class FeedbackModule : IApiModule
.WithBodyLimit(FeedbackImages.MaxRequestBytes);
api.MapGet(ApiRoutes.FeedbackOverview, Overview).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.FeedbackForms, GetForms).RequireAdmin();
api.MapPut(ApiRoutes.FeedbackForms, PublishForms).RequirePermission(AdminPermissions.FeedbackForms).RequireSignedRequest()
.WithBodyLimit(FeedbackFormRules.MaxRequestBytes); // icônes importées comprises (bloc nginx dédié)
api.MapGet(ApiRoutes.GameFeedbackPattern, List).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackItemPattern, Get).RequirePermission(AdminPermissions.FeedbackRead);
api.MapGet(ApiRoutes.GameFeedbackAttachmentPattern, GetAttachment).RequirePermission(AdminPermissions.FeedbackRead);
@@ -56,7 +65,8 @@ public sealed class FeedbackModule : IApiModule
private static IResult Challenge(FeedbackStore store, FeedbackGuard guard) =>
store.Enabled ? Results.Json(guard.CreateChallenge(), ApiJson.Options) : NotConfigured();
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackGuard guard, AuditLog audit, TimeProvider time)
private static IResult Submit(HttpContext context, FeedbackStore store, FeedbackFormsStore forms, FeedbackGuard guard, AuditLog audit,
TimeProvider time)
{
if (!store.Enabled) return NotConfigured();
@@ -85,9 +95,22 @@ public sealed class FeedbackModule : IApiModule
return ApiResults.BadRequest("Ce jeu n'accepte pas de demandes sur ce serveur.");
}
// 3. Contenu (mêmes règles que le launcher).
// 3. Formulaire de la révision annoncée (409 si les formulaires ont été republiés pendant la saisie), puis contenu
// (mêmes règles que le launcher).
FeedbackForm form;
bool snapshot;
try
{
(form, snapshot) = forms.Resolve(submission.FormRevision, submission.Kind);
}
catch (ApiRejection ex)
{
audit.WriteAnonymousFailure("feedback.submit", null, null,
ex.Status == 409 ? $"formulaire dépassé (révision {submission.FormRevision})" : "type de demande refusé");
throw;
}
var errors = new List<string>();
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, errors);
var clean = FeedbackRules.Validate(submission with { GameId = gameId }, form, errors);
if (clean is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"contenu refusé ({errors.Count} erreur(s))");
@@ -102,7 +125,7 @@ public sealed class FeedbackModule : IApiModule
"Vous avez envoyé plusieurs demandes récemment : patientez un peu avant d'en envoyer une autre.");
}
var now = time.GetUtcNow();
var record = store.Add(gameId, clean, now);
var record = store.Add(gameId, clean, now, snapshot ? form : null);
if (record is null)
{
audit.WriteAnonymousFailure("feedback.submit", null, null, $"plafond atteint ({store.FullReason(gameId, now) ?? "?"})");
@@ -112,7 +135,8 @@ public sealed class FeedbackModule : IApiModule
// Audit SANS adresse IP : l'audit ne doit pas permettre de relier une demande « anonyme » à une adresse.
audit.Write("feedback.submit", AuditResults.Ok, target: record.Reference,
details: $"jeu {gameId} ; {FeedbackForms.KindLabel(record.Kind)}" + (record.Tech is null ? "" : " ; informations techniques jointes") +
details: $"jeu {gameId} ; {KindLabel(record)}" +
(record.FormRevision > 0 ? $" ; formulaires révision {record.FormRevision}" : "") + (record.Tech is null ? "" : " ; informations techniques jointes") +
(record.AttachmentCount > 0 ? $" ; {record.AttachmentCount} capture(s) annoncée(s)" : ""));
return Results.Json(new FeedbackSubmitted
{
@@ -151,6 +175,58 @@ public sealed class FeedbackModule : IApiModule
return Results.Json(new FeedbackAttachmentStored { Index = index }, ApiJson.Options);
}
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés), sinon nom d'origine.</summary>
private static string KindLabel(FeedbackRecord record) => record.Form?.Label ?? FeedbackForms.KindLabel(record.Kind);
// ------------------------------------------------------------------ Formulaires (communs à tous les jeux)
private static IResult GetForms(FeedbackFormsStore forms, AdminStore admins) => Results.Json(FormsState(forms, admins), ApiJson.Options);
private static FeedbackFormsState FormsState(FeedbackFormsStore forms, AdminStore admins)
{
var current = forms.Current;
return new FeedbackFormsState
{
Document = current,
PublishingEnabled = forms.PublishingEnabled,
PublicUrl = forms.PublicUrl,
UpdatedBy = ContentSignatures.SignerName(admins.Read(), current.Signature)
};
}
/// <summary>
/// Publication des formulaires : requête signée (permission feedback.forms) ET formulaires signés par la clé de la
/// session sur leur condensé (les launchers revérifient avec le trousseau : autorisation « forms »), règles complètes,
/// révision attendue (409 si quelqu'un a publié entre-temps), audit.
/// </summary>
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
{
if (request.Document is null) return ApiResults.BadRequest("Formulaires absents.");
var document = request.Document with { UpdatedAt = null };
var actor = context.Admin();
if (document.Signature is not { } signature)
return ApiResults.Error(400, ApiErrorCodes.SignatureRequired, "Les formulaires doivent être signés.");
ContentSignatures.RequireFromActor(signature, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId,
FeedbackFormRules.Digest(document), actor, time.GetUtcNow());
FeedbackFormsDocument published, previous;
try
{
(published, previous) = forms.Publish(document, request.BaseRevision, time.GetUtcNow());
}
catch (IOException ex)
{
logger.LogError(ex, "Publication des formulaires des demandes");
return ApiResults.Error(503, ApiErrorCodes.NotConfigured,
"Le serveur n'a pas pu écrire les formulaires publics : vérifiez le dossier et ses droits (server/deploy/LISEZMOI.md).");
}
audit.Write("feedback.forms", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
$"révision {published.Revision}", $"{FeedbackFormsStore.Describe(previous, published)} ; signé (clé {signature.KeyId})");
return Results.Json(FormsState(forms, admins), ApiJson.Options);
}
// ------------------------------------------------------------------ Administrateurs
private static IResult Overview(FeedbackStore store, IOptions<LauncherApiOptions> options, TimeProvider time)
@@ -172,7 +248,7 @@ public sealed class FeedbackModule : IApiModule
{
var game = store.ResolveGame(gameId);
if (game is null) return ApiResults.NotFound("Ce jeu ne reçoit pas de demandes sur ce serveur.");
if (kind is not null && FeedbackForms.Find(kind) is null) return ApiResults.BadRequest("Type de demande inconnu.");
if (kind is not null && !FeedbackFormRules.IsValidId(kind)) return ApiResults.BadRequest("Type de demande inconnu.");
if (!FeedbackStore.IsValidStatusFilter(status)) return ApiResults.BadRequest("Statut inconnu.");
if (cursor is { Length: > 60 }) return ApiResults.BadRequest("Curseur invalide.");
@@ -194,7 +270,7 @@ public sealed class FeedbackModule : IApiModule
// demande et par clé toutes les 10 minutes (sans le contenu).
if (consultations.ShouldRecord(actor.Key.KeyId, "item:" + record.Id, continuation: true))
audit.Write("feedback.view", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
record.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(record.Kind)}");
record.Reference, $"jeu {game} ; {KindLabel(record)}");
return Results.Json(record.ToDetail(), ApiJson.Options);
}
@@ -242,7 +318,7 @@ public sealed class FeedbackModule : IApiModule
var actor = context.Admin();
var deleted = store.Delete(game, id);
audit.Write("feedback.delete", AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
deleted.Reference, $"jeu {game} ; {FeedbackForms.KindLabel(deleted.Kind)} ; {FeedbackStatuses.Label(deleted.Status)}");
deleted.Reference, $"jeu {game} ; {KindLabel(deleted)} ; {FeedbackStatuses.Label(deleted.Status)}");
return Results.NoContent();
}
}
@@ -0,0 +1,189 @@
using System.Text;
using System.Text.Json;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Server.Hosting;
using Microsoft.Extensions.Options;
namespace GameLauncher.Server.Storage;
/// <summary>
/// Formulaires des demandes des joueurs (communs à tous les jeux), publiés depuis le menu d'administration.
/// • Référence : feedback-forms.json dans les données privées (+ historique des 20 dernières révisions) ;
/// • Publication : copie dans le fichier PUBLIC servi par nginx, à côté du trousseau (les launchers en déduisent
/// l'adresse), écrite atomiquement, lisible par tous ;
/// • Toujours signés (autorisation « forms » du trousseau) : le serveur vérifie que la signature est celle de la
/// session et que le document respecte les règles, le launcher vérifie la signature avec le trousseau.
/// Rien de publié : les formulaires d'origine (révision 0) s'appliquent.
/// SQLite n'est pas utilisé : un seul petit document, modifié rarement, à publier de toute façon en fichier.
/// </summary>
public sealed class FeedbackFormsStore
{
public const string FileName = "feedback-forms.json";
private const string HistoryFolder = "feedback-forms-history";
private const int HistoryLength = 20;
private static readonly TimeSpan LockTimeout = TimeSpan.FromSeconds(5);
private readonly string _path;
private readonly string _lockPath;
private readonly string _historyDirectory;
private readonly string? _publicFile;
private readonly object _lock = new();
private FeedbackFormsDocument? _current;
public FeedbackFormsStore(IOptions<LauncherApiOptions> options)
: this(options.Value.DataDirectoryFullPath, options.Value.FeedbackFormsPublicFileFullPath, options.Value.FeedbackFormsPublicUrl) { }
public FeedbackFormsStore(string dataDirectory, string? publicFile, string? publicUrl = null)
{
SecureFiles.EnsureDirectory(dataDirectory);
_path = Path.Combine(dataDirectory, FileName);
_lockPath = _path + ".lock";
_historyDirectory = Path.Combine(dataDirectory, HistoryFolder);
_publicFile = publicFile;
PublicUrl = publicUrl;
}
public bool PublishingEnabled => _publicFile is not null;
public string? PublicUrl { get; }
/// <summary>Formulaires en vigueur : ceux publiés, sinon ceux d'origine (révision 0).</summary>
public FeedbackFormsDocument Current
{
get
{
lock (_lock) return _current ??= Read() ?? FeedbackFormRules.Defaults();
}
}
/// <summary>
/// Formulaire à utiliser pour valider une demande, ou la raison du refus. Révision 0 (formulaires d'origine) :
/// toujours acceptée (launchers 3.0 / 3.1, formulaires publiés illisibles chez le joueur), sauf pour un type
/// masqué ou supprimé depuis. Autre révision : seule celle en vigueur est acceptée (409 : le launcher recharge les
/// formulaires).
/// </summary>
/// <returns>Le formulaire du type, et s'il faut le conserver avec la demande (formulaires publiés).</returns>
public (FeedbackForm Form, bool Snapshot) Resolve(long revision, string? kind)
{
var current = Current;
if (current.Find(kind) is { Hidden: true } ||
(current.Revision > 0 && current.Find(kind) is null && FeedbackForms.Find(kind) is not null))
throw ApiRejection.BadRequest("Ce type de demande n'est plus proposé.");
if (revision == 0)
return (FeedbackForms.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), false);
if (revision != current.Revision)
throw new ApiRejection(409, ApiErrorCodes.FeedbackFormChanged,
"Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis envoyez de nouveau.");
return (current.Find(kind) ?? throw ApiRejection.BadRequest("Type de demande inconnu."), current.Revision > 0);
}
/// <summary>Formulaires publiés (null : aucun).</summary>
private FeedbackFormsDocument? Read()
{
if (!File.Exists(_path)) return null;
return FeedbackFormRules.Parse(File.ReadAllBytes(_path)) ?? throw new InvalidDataException($"{FileName} est vide.");
}
/// <summary>
/// Publie des formulaires signés (signature déjà contrôlée par l'appelant) : règles complètes, révision attendue
/// (409 si une autre publication a eu lieu), révision suivante obligatoire.
/// </summary>
/// <returns>Formulaires publiés (avec la date) et ceux d'avant.</returns>
public (FeedbackFormsDocument Published, FeedbackFormsDocument Previous) Publish(FeedbackFormsDocument document, long baseRevision,
DateTimeOffset now)
{
if (_publicFile is null)
throw new ApiRejection(503, ApiErrorCodes.NotConfigured,
"Publication des formulaires non configurée : le fichier public du trousseau (LauncherApi:Keyring:PublicFile, ou " +
"LauncherApi:Catalog:PublicFile) est nécessaire, les formulaires sont publiés à côté.");
if (document.Signature is null || string.IsNullOrEmpty(document.Signature.Value))
throw new ApiRejection(400, ApiErrorCodes.SignatureRequired, "Formulaires non signés.");
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors))
throw ApiRejection.BadRequest(string.Join(" ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} autres)" : ""));
lock (_lock)
using (SecureFiles.AcquireLock(_lockPath, LockTimeout))
{
var previous = Read() ?? FeedbackFormRules.Defaults();
if (baseRevision != previous.Revision)
throw ApiRejection.Conflict(
$"Les formulaires ont été publiés entre-temps (révision {previous.Revision}) : rechargez la page, puis refaites vos modifications.",
ApiErrorCodes.RevisionConflict);
if (document.Revision != previous.Revision + 1)
throw ApiRejection.BadRequest($"Révision invalide : {previous.Revision + 1} attendue.");
var published = document with { UpdatedAt = DateTimeOffset.FromUnixTimeSeconds(now.ToUnixTimeSeconds()) };
var json = FeedbackFormRules.Serialize(published);
if (Encoding.UTF8.GetByteCount(json) > FeedbackFormRules.MaxBytes)
throw ApiRejection.BadRequest($"Formulaires trop volumineux (au plus {FeedbackFormRules.MaxBytes / 1024} Ko).");
// Public d'abord : en cas d'échec, la référence privée (qui porte la révision) reste l'ancienne.
PublicFileWriter.Write(_publicFile, json, "formulaires des demandes");
SecureFiles.WriteAllTextAtomic(_path, json, keepBackup: true);
WriteHistory(published.Revision, json);
_current = published;
return (published, previous);
}
}
private void WriteHistory(long revision, string json)
{
try
{
SecureFiles.EnsureDirectory(_historyDirectory);
SecureFiles.WriteAllTextAtomic(Path.Combine(_historyDirectory, $"feedback-forms-{revision:D6}.json"), json, keepBackup: false);
foreach (var old in Directory.GetFiles(_historyDirectory, "feedback-forms-*.json").OrderByDescending(f => f, StringComparer.Ordinal)
.Skip(HistoryLength))
File.Delete(old);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// L'historique est une aide : son échec n'annule pas la publication.
}
}
/// <summary>
/// Résumé des changements pour l'audit (types ajoutés, supprimés, réordonnés, masqués ou réaffichés ; champs ajoutés,
/// retirés, modifiés ; captures d'écran, icône).
/// </summary>
public static string Describe(FeedbackFormsDocument before, FeedbackFormsDocument after)
{
var changes = new List<string>();
var added = after.Forms.Where(f => before.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
var removed = before.Forms.Where(f => after.Find(f.Kind) is null).Select(f => $"« {f.Label} »").ToList();
if (added.Count > 0) changes.Add($"type(s) ajouté(s) : {string.Join(", ", added)}");
if (removed.Count > 0) changes.Add($"type(s) supprimé(s) : {string.Join(", ", removed)}");
var kept = after.Forms.Select(f => f.Kind).Where(k => before.Find(k) is not null).ToList();
if (!before.Forms.Select(f => f.Kind).Where(kept.Contains).SequenceEqual(kept)) changes.Add("ordre des types modifié");
foreach (var form in after.Forms)
{
var old = before.Find(form.Kind);
if (old is null) continue;
var parts = new List<string>();
if (old.Hidden != form.Hidden) parts.Add(form.Hidden ? "masqué" : "de nouveau proposé");
if (old.Label != form.Label || old.Description != form.Description || old.TitleHint != form.TitleHint ||
old.TechByDefault != form.TechByDefault)
parts.Add("présentation modifiée");
if (old.IconKey != form.IconKey || old.CustomIcon != form.CustomIcon)
parts.Add(form.CustomIcon is null ? "icône d'origine" : "icône importée");
if (old.AllowAttachments != form.AllowAttachments)
parts.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
var addedFields = newIds.Count(id => !oldIds.Contains(id));
var removedFields = oldIds.Count(id => !newIds.Contains(id));
var changed = form.Fields.Count(f => old.Field(f.Id) is { } o &&
JsonSerializer.Serialize(o) != JsonSerializer.Serialize(f));
if (addedFields > 0) parts.Add($"+{addedFields} champ(s)");
if (removedFields > 0) parts.Add($"-{removedFields} champ(s)");
if (changed > 0) parts.Add($"{changed} champ(s) modifié(s)");
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
parts.Add("ordre modifié");
var name = old.Label == form.Label ? old.Label : $"{old.Label} → « {form.Label} »";
if (parts.Count > 0) changes.Add($"{name} : {string.Join(", ", parts)}");
}
return changes.Count == 0 ? "aucun changement" : string.Join(" ; ", changes);
}
}
@@ -21,6 +21,13 @@ public sealed record FeedbackRecord
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { get; init; }
/// <summary>Révision des formulaires utilisés (0 : formulaires d'origine).</summary>
public long FormRevision { get; init; }
/// <summary>Formulaire tel qu'à l'envoi (formulaires publiés depuis le menu) ; null : formulaires d'origine.</summary>
public FeedbackForm? Form { get; init; }
public DateTimeOffset ReceivedAt { get; init; }
public string Title { get; init; } = "";
public Dictionary<string, string> Fields { get; init; } = new();
@@ -47,13 +54,7 @@ public sealed record FeedbackRecord
Status = Status,
ReceivedAt = ReceivedAt,
StatusChangedAt = StatusChangedAt,
Badge = Kind switch
{
FeedbackKinds.Review when Fields.TryGetValue("rating", out var rating) => $"{rating}/5",
FeedbackKinds.Bug when Fields.TryGetValue("severity", out var severity) =>
FeedbackForms.Find(FeedbackKinds.Bug)?.Field("severity")?.DisplayValue(severity),
_ => null
},
Badge = (Form ?? FeedbackForms.Find(Kind))?.Badge(Fields),
HasNote = !string.IsNullOrEmpty(AdminNote),
HasLinks = FeedbackLinks.Contains(Title + "\n" + string.Join("\n", Fields.Values)),
AttachmentCount = Attachments.Count,
@@ -64,6 +65,8 @@ public sealed record FeedbackRecord
{
Summary = ToSummary(),
FormVersion = FormVersion,
FormRevision = FormRevision,
Form = Form,
Fields = Fields,
Tech = Tech,
Attachments = Attachments.OrderBy(a => a.Index).ToList(),
@@ -145,7 +148,8 @@ public sealed class FeedbackStore
}
/// <summary>Enregistre une demande déjà validée (<see cref="FeedbackRules.Validate"/>). Null : plafond atteint.</summary>
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now)
/// <param name="form">Formulaire utilisé, conservé avec la demande (null : formulaires d'origine).</param>
public FeedbackRecord? Add(string gameId, FeedbackSubmission submission, DateTimeOffset now, FeedbackForm? form = null)
{
lock (_lock)
{
@@ -164,6 +168,8 @@ public sealed class FeedbackStore
GameId = gameId,
Kind = submission.Kind,
FormVersion = submission.FormVersion,
FormRevision = form is null ? 0 : submission.FormRevision,
Form = form,
ReceivedAt = now,
Title = submission.Title,
Fields = new Dictionary<string, string>(submission.Fields, StringComparer.Ordinal),
+43
View File
@@ -415,6 +415,45 @@ Un fichier d'audit mensuel est supprimé quand tous ses jours dépassent la dur
donc compter quelques semaines de plus). Pense aussi à borner les journaux du système (voir la fiche RGPD) :
`journald` (`MaxRetentionSec=1year` dans `/etc/systemd/journald.conf`) et nginx (`logrotate`).
## 3 nonies. Formulaires des demandes modifiables depuis le menu (PC et VPS)
Les types de demande du bouton **Aide** (avis, demande d'évolution, bug, autre au départ ; 1 à 8) se gèrent depuis le
menu d'administration : ajout, suppression, ordre d'affichage, nom, description, icône (l'une des quatre d'origine ou
un fichier **.ico** contenant une image 64 × 64), captures d'écran autorisées ou non, champs (zone de texte, texte
court, liste déroulante, cases à cocher, case oui/non, étoiles), types masqués. Les formulaires sont **communs à tous les jeux** et **signés** : un serveur
compromis ne peut pas faire afficher aux joueurs un faux formulaire (« entrez votre mot de passe »). Les libellés et
aides ne peuvent contenir **aucun lien**.
Prérequis : étapes 3 bis (catalogue), 3 quinquies (trousseau) et 3 octies (demandes).
1. **Serveur** : mets-le à jour (étape 8). Rien à ajouter dans `appsettings.Production.json` : les formulaires sont
écrits à côté du trousseau (`/srv/exovival/www/catalog/feedback-forms.json`, 644).
2. **nginx** : ajoute
- le bloc `location = /catalog/feedback-forms.json { … }` de `server/deploy/nginx-catalog.conf`, à côté de celui
du trousseau (lecture par les launchers) ;
- le bloc `location = /api/v1/feedback/forms { … }` de `server/deploy/nginx-api.conf` (publication depuis le
menu : formulaires et icônes importées, 300 Ko au plus ; sans lui, une publication de plus de 64 Ko est refusée) ;
puis `sudo nginx -t && sudo systemctl reload nginx`.
3. **Launcher 3.2** : publie-le (mise à jour signée habituelle) **avant** l'étape 4, et laisse aux joueurs le temps de
le recevoir. Les launchers plus anciens ne connaissent pas la nouvelle autorisation « formulaires » du trousseau :
ils refusent le nouveau trousseau et gardent le précédent (aucune gêne pour jouer, mais un appareil ajouté au
trousseau en même temps ne serait pas reconnu par eux).
4. **Permission et trousseau** :
1. page **Groupes et permissions** : donne `feedback.forms` (« Formulaires des demandes ») au groupe qui doit modifier
les formulaires (le groupe Propriétaire l'a d'office ; Administrateur l'a sur une installation neuve) ;
2. signe un nouveau trousseau (étape 3 quinquies, point 3) : l'autorisation **« formulaires des demandes »** des
appareils concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Publier** depuis le menu, page **« Formulaires des demandes »** (modifier, masquer, aperçu, « Publier »). Les launchers lisent le fichier à l'ouverture de
la fenêtre « Aide » et vérifient la signature comme pour le catalogue (`contentSigning`) ; s'ils ne peuvent pas
(hors ligne, signature refusée), ils proposent les formulaires d'origine, toujours acceptés par le serveur.
Compatibilité : chaque demande annonce la révision des formulaires utilisés. Les launchers 3.0 / 3.1 (révision 0,
formulaires d'origine) restent acceptés, sauf pour un type masqué. Si les formulaires sont republiés pendant qu'un
joueur remplit le sien, le serveur répond 409 : le launcher 3.2 relit les formulaires et garde la saisie des champs
inchangés. Chaque demande conserve le formulaire tel qu'à l'envoi : elle reste lisible si un champ est renommé ou
supprimé ensuite. Historique des 20 dernières publications : `feedback-forms-history/` dans les données privées.
## 4. Créer ta clé d'administrateur (PC)
Double-clique `tools\admin-key.bat`. Choisis une phrase de passe (12 caractères minimum). Il crée :
@@ -543,9 +582,13 @@ recréer les administrateurs.
├─ publications/ publications en cours (version, fichiers restant à envoyer)
├─ feedback/<jeu>/AAAA-MM/<id>.json demandes des joueurs (une par fichier, 600 ; purge automatique)
│ et leurs captures <id>-<n>.png|jpg (sans métadonnées)
├─ feedback-forms.json (+ .bak) formulaires des demandes publiés (référence de la révision)
├─ feedback-forms-history/ 20 dernières révisions publiées
└─ data-protection/ clés internes d'ASP.NET Core (inutilisées par l'API)
/srv/exovival/www/catalog/ (exemple) dossier public servi par nginx (deploy:www-data, 2750)
├─ catalog.json catalogue lu par les launchers (écrit par le service, 644)
├─ keyring.json trousseau publié (écrit par le service, 644)
├─ feedback-forms.json formulaires des demandes signés (écrit par le service, 644)
└─ images/ images du catalogue (envoyées en SFTP)
/etc/systemd/system/launcher-api.service
/etc/systemd/system/launcher-api.service.d/catalog.conf écriture du dossier public (conservé)
+23 -1
View File
@@ -8,7 +8,8 @@
# 2) Dans le bloc HTTPS (port 443, créé par certbot) du site
# /etc/nginx/sites-available/dl-exovival, AVANT « location / { … } » :
# (les blocs ci-dessous ; le deuxième, envoi des images du catalogue, voir LISEZMOI.md étape 3 septies ;
# le troisième, demandes des joueurs, voir LISEZMOI.md étape 3 octies)
# les suivants, demandes des joueurs, LISEZMOI.md étape 3 octies ; le dernier, formulaires des demandes,
# LISEZMOI.md étape 3 nonies)
#
# Puis : sudo nginx -t && sudo systemctl reload nginx
# =============================================================================
@@ -119,3 +120,24 @@ location ^~ /api/v1/feedback/attachments/ {
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# Publication des formulaires des demandes (page « Formulaires des demandes », administrateurs) : formulaires signés
# avec leurs icônes importées (256 Ko au plus). Correspondance EXACTE : la lecture (GET) passe aussi par ce bloc,
# avec les limites de débit des administrateurs ; la route exige une session, feedback.forms et une requête signée.
location = /api/v1/feedback/forms {
limit_req zone=api_par_ip burst=20 nodelay;
limit_req_status 429;
client_max_body_size 300k;
proxy_pass http://127.0.0.1:5080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
+11 -1
View File
@@ -1,5 +1,6 @@
# =============================================================================
# Catalogue des jeux et trousseau (écrits par la Launcher API) — voir LISEZMOI.md, étapes 3 bis et 3 quinquies :
# Catalogue des jeux, trousseau et formulaires des demandes (écrits par la Launcher API) — voir LISEZMOI.md,
# étapes 3 bis, 3 quinquies et 3 nonies :
#
# Dans le bloc HTTPS (port 443) du site /etc/nginx/sites-available/dl-exovival,
# AVANT « location / { … } » : (le bloc ci-dessous)
@@ -26,3 +27,12 @@ location = /catalog/keyring.json {
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Formulaires des demandes (bouton « Aide ») : publiés depuis le menu, signés (autorisation « forms » du trousseau),
# lus par les launchers à l'ouverture de la fenêtre « Aide ».
location = /catalog/feedback-forms.json {
limit_except GET { deny all; }
default_type application/json;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
add_header X-Content-Type-Options "nosniff" always;
}
@@ -295,6 +295,26 @@ public sealed class AdminSession : IAsyncDisposable
public Task DeleteFeedbackAsync(string gameId, string id, CancellationToken ct = default) =>
SendSignedAsync<NoContent>(HttpMethod.Delete, ApiRoutes.GameFeedbackItem(CheckGameId(gameId), CheckFeedbackId(id)), null, ct);
/// <summary>Formulaires des demandes en vigueur (communs à tous les jeux ; révision 0 : formulaires d'origine).</summary>
public Task<FeedbackFormsState> GetFeedbackFormsAsync(CancellationToken ct = default) =>
GetAsync<FeedbackFormsState>(ApiRoutes.FeedbackForms, ct);
/// <summary>
/// Publie les formulaires : révision <paramref name="baseRevision"/> + 1, signés par la clé de cette session (autorisation
/// « forms » du trousseau requise chez les joueurs), puis envoyés en requête signée.
/// </summary>
public async Task<FeedbackFormsState> PublishFeedbackFormsAsync(IReadOnlyList<FeedbackForm> forms, long baseRevision,
CancellationToken ct = default)
{
var document = new FeedbackFormsDocument { Revision = baseRevision + 1, Forms = forms.ToList() };
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors)) throw new ArgumentException(string.Join(" ", errors.Take(5)), nameof(forms));
var signature = await SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), ct)
.ConfigureAwait(false);
return await SendSignedAsync<FeedbackFormsState>(HttpMethod.Put, ApiRoutes.FeedbackForms,
new FeedbackFormsPublishRequest { BaseRevision = baseRevision, Document = document with { Signature = signature } }, ct).ConfigureAwait(false);
}
/// <summary>Contenu d'une capture d'écran (à revérifier avec FeedbackImages.Sanitize avant de l'afficher).</summary>
public Task<byte[]> GetFeedbackAttachmentAsync(string gameId, string id, int index, CancellationToken ct = default) =>
SendCoreAsync<byte[]>(HttpMethod.Get, ApiRoutes.GameFeedbackAttachment(CheckGameId(gameId), CheckFeedbackId(id), index), null,
+3
View File
@@ -123,6 +123,9 @@ public static class ApiRoutes
/// <summary>Compteurs par jeu (feedback.read).</summary>
public const string FeedbackOverview = "v1/feedback/overview";
/// <summary>Formulaires des demandes : lecture (feedback.forms ou feedback.read), publication signée (PUT, feedback.forms).</summary>
public const string FeedbackForms = "v1/feedback/forms";
public const string GameFeedbackPattern = "v1/games/{gameId}/feedback";
public const string GameFeedbackItemPattern = "v1/games/{gameId}/feedback/{id}";
@@ -72,6 +72,9 @@ public static class ApiErrorCodes
/// <summary>Demandes des joueurs : plafond atteint (nombre, taille, espace disque), envoi refusé pour l'instant.</summary>
public const string FeedbackFull = "feedback_full";
/// <summary>Les formulaires des demandes ont été republiés pendant la saisie : le launcher les recharge.</summary>
public const string FeedbackFormChanged = "feedback_form_changed";
/// <summary>Demandes des joueurs : défi anti-spam absent, expiré, déjà utilisé ou mal résolu.</summary>
public const string ProofInvalid = "proof_invalid";
}
@@ -42,6 +42,13 @@ public sealed record FeedbackSubmission
public string GameId { get; init; } = "";
public string Kind { get; init; } = "";
public int FormVersion { get; init; } = FeedbackForms.CurrentVersion;
/// <summary>
/// Révision des formulaires utilisés (<see cref="FeedbackFormsDocument.Revision"/>) ; 0 : formulaires d'origine
/// (launchers 3.0 / 3.1, ou formulaires publiés illisibles). Une autre révision que celle en vigueur : 409.
/// </summary>
public long FormRevision { get; init; }
public string Title { get; init; } = "";
/// <summary>Valeurs des champs du formulaire (identifiant du champ → valeur).</summary>
@@ -170,6 +177,16 @@ public sealed record FeedbackDetail
{
public FeedbackSummary Summary { get; init; } = new();
public int FormVersion { get; init; }
/// <summary>Révision des formulaires avec laquelle la demande a été envoyée (0 : formulaires d'origine).</summary>
public long FormRevision { get; init; }
/// <summary>
/// Formulaire tel qu'il était à l'envoi (libellés, types, choix) : une demande reste lisible même si le formulaire a
/// changé depuis. Null : demande reçue avant la 3.2 (formulaires d'origine).
/// </summary>
public FeedbackForm? Form { get; init; }
public Dictionary<string, string> Fields { get; init; } = new();
public FeedbackTechInfo? Tech { get; init; }
@@ -1,16 +1,28 @@
using System.Text.Json.Serialization;
namespace GameLauncher.Contracts.Feedback;
/// <summary>Type d'un champ de formulaire.</summary>
/// <summary>Type d'un champ de formulaire (écrit en toutes lettres dans le fichier des formulaires).</summary>
[JsonConverter(typeof(JsonStringEnumConverter<FeedbackFieldKind>))]
public enum FeedbackFieldKind
{
/// <summary>Texte libre sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
/// <summary>Zone de texte sur plusieurs lignes (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
Multiline,
/// <summary>Un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
/// <summary>Liste déroulante : un choix parmi <see cref="FeedbackField.Options"/> (valeur : identifiant de l'option).</summary>
Choice,
/// <summary>Note de 1 à 5 (valeur : « 1 » à « 5 »).</summary>
Rating
/// <summary>Note en étoiles, de 1 à <see cref="FeedbackField.RatingMax"/> (valeur : « 1 », « 2 »…).</summary>
Rating,
/// <summary>Texte court sur une ligne (<see cref="FeedbackField.MaxLength"/> caractères au plus).</summary>
Text,
/// <summary>Cases à cocher : plusieurs choix (valeur : identifiants des options cochées, séparés par des virgules).</summary>
Checkboxes,
/// <summary>Case oui/non (valeur : « yes » ou « no » ; obligatoire = doit être cochée).</summary>
YesNo
}
/// <summary>Option d'un champ à choix : identifiant stable (envoyé au serveur) et libellé affiché.</summary>
@@ -20,6 +32,8 @@ public sealed record FeedbackOption(string Id, string Label);
/// <param name="Id">Identifiant stable (clé dans <see cref="FeedbackSubmission.Fields"/>).</param>
/// <param name="Label">Libellé affiché (joueur et menu d'administration).</param>
/// <param name="Hint">Aide sous le champ, ou texte indicatif dans la zone de saisie.</param>
/// <param name="RatingMax">Nombre d'étoiles d'une note (3, 5 ou 10).</param>
/// <param name="ShowInList">Valeur affichée en aperçu dans la liste des demandes du menu (un champ par formulaire au plus).</param>
public sealed record FeedbackField(
string Id,
string Label,
@@ -27,16 +41,25 @@ public sealed record FeedbackField(
bool Required,
string? Hint = null,
int MaxLength = FeedbackRules.MaxMultilineLength,
IReadOnlyList<FeedbackOption>? Options = null)
IReadOnlyList<FeedbackOption>? Options = null,
int RatingMax = 5,
bool ShowInList = false)
{
[JsonIgnore]
public IReadOnlyList<FeedbackOption> Choices => Options ?? Array.Empty<FeedbackOption>();
/// <summary>Libellé d'une valeur (option d'un choix, « 4/5 » pour une note, texte sinon).</summary>
/// <summary>Champ à saisir au clavier (zone de texte ou texte court).</summary>
[JsonIgnore]
public bool IsText => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
/// <summary>Libellé d'une valeur (option(s) choisie(s), « 4/5 » pour une note, « Oui »/« Non », texte sinon).</summary>
public string DisplayValue(string? value) => Kind switch
{
_ when string.IsNullOrEmpty(value) => "",
FeedbackFieldKind.Choice => Choices.FirstOrDefault(o => o.Id == value)?.Label ?? value!,
FeedbackFieldKind.Rating => $"{value}/5",
FeedbackFieldKind.Checkboxes => string.Join(", ", value!.Split(',').Select(id => Choices.FirstOrDefault(o => o.Id == id)?.Label ?? id)),
FeedbackFieldKind.Rating => $"{value}/{RatingMax}",
FeedbackFieldKind.YesNo => value == FeedbackRules.Yes ? "Oui" : "Non",
_ => value!
};
}
@@ -46,6 +69,9 @@ public sealed record FeedbackField(
/// <param name="IconKey">Icône du launcher (ressource « Icon.&lt;clé&gt; »).</param>
/// <param name="TitleHint">Texte indicatif du titre de la demande.</param>
/// <param name="TechByDefault">« Joindre les informations techniques » coché à l'ouverture du formulaire.</param>
/// <param name="Hidden">Type masqué : n'est plus proposé aux joueurs (les demandes déjà reçues restent consultables).</param>
/// <param name="AllowAttachments">Captures d'écran proposées pour ce type (3 au plus, contrôlées et nettoyées).</param>
/// <param name="CustomIcon">Icône importée (PNG 64 × 64 en base64, <see cref="FeedbackIcons"/>) ; null : <paramref name="IconKey"/>.</param>
public sealed record FeedbackForm(
string Kind,
string Label,
@@ -53,9 +79,24 @@ public sealed record FeedbackForm(
string IconKey,
string TitleHint,
bool TechByDefault,
IReadOnlyList<FeedbackField> Fields)
IReadOnlyList<FeedbackField> Fields,
bool Hidden = false,
bool AllowAttachments = true,
string? CustomIcon = null)
{
public FeedbackField? Field(string id) => Fields.FirstOrDefault(f => f.Id == id);
/// <summary>Octets de l'icône importée (null : icône d'origine <see cref="IconKey"/>).</summary>
public byte[]? CustomIconBytes() => FeedbackIcons.Decode(CustomIcon);
public FeedbackField? Field(string id) => (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.Id == id);
/// <summary>Valeur montrée en aperçu dans la liste du menu (champ « afficher en aperçu »), ou null.</summary>
public string? Badge(IReadOnlyDictionary<string, string>? values)
{
var field = (Fields ?? Array.Empty<FeedbackField>()).FirstOrDefault(f => f.ShowInList);
return field is not null && values is not null && values.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value)
? field.DisplayValue(value)
: null;
}
}
/// <summary>Types de demande (identifiants stables, jamais traduits).</summary>
@@ -68,14 +109,14 @@ public static class FeedbackKinds
}
/// <summary>
/// Formulaires des demandes des joueurs, décrits UNE fois ici : le launcher les génère, le serveur valide chaque
/// demande avec (<see cref="FeedbackRules"/>), le menu d'administration les utilise pour afficher les libellés.
/// Ajouter un champ = le déclarer ici (champ facultatif de préférence : les anciens launchers continuent de
/// fonctionner) ; changer le sens d'un champ existant = nouvel identifiant et <see cref="CurrentVersion"/> + 1.
/// Formulaires D'ORIGINE des demandes des joueurs (révision 0). Ils servent tant qu'aucun formulaire n'a été publié
/// depuis le menu d'administration (<see cref="FeedbackFormsDocument"/>), aux launchers qui n'ont pas pu lire les
/// formulaires publiés, aux launchers 3.0 / 3.1, et à l'affichage des demandes reçues avant la 3.2.
/// Ne pas les modifier : les formulaires se changent désormais depuis le menu (page « Formulaires des demandes »).
/// </summary>
public static class FeedbackForms
{
/// <summary>Version des formulaires (enregistrée avec chaque demande).</summary>
/// <summary>Version du format des demandes (enregistrée avec chaque demande).</summary>
public const int CurrentVersion = 1;
private static FeedbackOption O(string id, string label) => new(id, label);
@@ -85,7 +126,7 @@ public static class FeedbackForms
new FeedbackForm(FeedbackKinds.Review, "Avis", "Dites-nous ce que vous pensez du jeu.", "Star",
"Votre avis en une phrase", TechByDefault: false, new[]
{
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true),
new FeedbackField("rating", "Note", FeedbackFieldKind.Rating, Required: true, ShowInList: true),
new FeedbackField("topic", "Sujet", FeedbackFieldKind.Choice, Required: true, Options: new[]
{
O("overall", "Le jeu dans son ensemble"), O("gameplay", "Gameplay"), O("graphics", "Graphismes"),
@@ -129,7 +170,7 @@ public static class FeedbackForms
O("performance", "Performances (lenteurs, saccades)"), O("install", "Installation ou mise à jour"),
O("launcher", "Launcher"), O("other", "Autre")
}),
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, Options: new[]
new FeedbackField("severity", "Gravité", FeedbackFieldKind.Choice, Required: true, ShowInList: true, Options: new[]
{
O("minor", "Mineur"), O("annoying", "Gênant"), O("blocking", "Bloquant"), O("crash", "Plantage")
}),
@@ -0,0 +1,283 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using GameLauncher.Contracts.Signing;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, communs à tous les jeux).
/// Fichier PUBLIC servi par nginx à côté du trousseau, signé par la clé d'un administrateur autorisée « forms » dans le
/// trousseau : le launcher des joueurs le vérifie comme le catalogue avant d'afficher quoi que ce soit.
/// Révision 0 = formulaires d'origine (<see cref="FeedbackForms"/>), jamais publiés.
/// </summary>
public sealed record FeedbackFormsDocument
{
public const string FormatName = "gamelauncher-feedback-forms";
public const int CurrentVersion = 1;
public string Format { get; init; } = FormatName;
public int Version { get; init; } = CurrentVersion;
/// <summary>Révision : +1 à chaque publication (couverte par la signature). Envoyée avec chaque demande.</summary>
public long Revision { get; init; }
/// <summary>Date de publication (posée par le serveur, hors signature).</summary>
public DateTimeOffset? UpdatedAt { get; init; }
/// <summary>Types de demande (1 à 8), dans l'ordre d'affichage aux joueurs, masqués ou non.</summary>
public List<FeedbackForm> Forms { get; init; } = new();
public ContentSignature? Signature { get; init; }
public FeedbackForm? Find(string? kind) => (Forms ?? new List<FeedbackForm>()).FirstOrDefault(f => f.Kind == kind);
/// <summary>Types proposés aux joueurs (non masqués), dans l'ordre du document.</summary>
[JsonIgnore]
public IEnumerable<FeedbackForm> Visible => (Forms ?? new List<FeedbackForm>()).Where(f => f is not null && !f.Hidden);
}
/// <summary>État des formulaires pour le menu d'administration (GET v1/feedback/forms).</summary>
public sealed record FeedbackFormsState
{
/// <summary>Formulaires en vigueur (ceux d'origine, révision 0, si rien n'a été publié).</summary>
public FeedbackFormsDocument Document { get; init; } = new();
/// <summary>Publication possible (fichier public configuré sur le serveur).</summary>
public bool PublishingEnabled { get; init; }
/// <summary>Adresse publique du fichier (lue par les launchers).</summary>
public string? PublicUrl { get; init; }
public string? UpdatedBy { get; init; }
}
/// <summary>Publication des formulaires (PUT v1/feedback/forms, requête signée).</summary>
public sealed record FeedbackFormsPublishRequest
{
/// <summary>Révision lue avant la modification (409 si une autre publication a eu lieu entre-temps).</summary>
public long BaseRevision { get; init; }
/// <summary>Formulaires signés, révision <see cref="BaseRevision"/> + 1.</summary>
public FeedbackFormsDocument Document { get; init; } = new();
}
/// <summary>
/// Règles des formulaires publiés, communes au menu (contrôle avant de signer), au serveur (refus avant publication)
/// et au launcher (refus d'un fichier hors limites, même signé). Les textes saisis par l'administrateur doivent être
/// déjà propres (une ligne, sans caractère invisible) et ne contiennent AUCUN lien : rien ne doit pouvoir renvoyer le
/// joueur vers un site depuis un formulaire.
/// </summary>
public static partial class FeedbackFormRules
{
/// <summary>Taille du fichier (icônes importées comprises). Publication : route dédiée de nginx et de l'API.</summary>
public const int MaxBytes = 256 * 1024;
/// <summary>Corps de la requête de publication (formulaires + révision de base).</summary>
public const int MaxRequestBytes = MaxBytes + 8 * 1024;
/// <summary>Nombre de types de demande (au moins un proposé aux joueurs).</summary>
public const int MaxForms = 8;
public const int MaxFields = 15;
public const int MinOptions = 2;
public const int MaxOptions = 20;
public const int MaxFormLabelLength = 40;
public const int MaxDescriptionLength = 120;
public const int MaxTitleHintLength = 80;
public const int MaxFieldLabelLength = 80;
public const int MaxHintLength = 200;
public const int MaxHintLines = 4;
public const int MaxOptionLabelLength = 60;
public const int MaxTextLength = 200;
public const int DefaultTextLength = 120;
public const int MinMultilineLength = 20;
/// <summary>Nombres d'étoiles proposés pour une note.</summary>
public static readonly IReadOnlyList<int> RatingSizes = new[] { 3, 5, 10 };
/// <summary>Icônes proposées pour un type de demande : les quatre d'origine (sinon icône importée, <see cref="FeedbackIcons"/>).</summary>
public static IReadOnlyList<string> Icons => FeedbackIcons.DefaultIcons;
/// <summary>Identifiant d'un champ ou d'un choix : stable, jamais affiché (clé des valeurs envoyées).</summary>
[GeneratedRegex(@"\A[a-z][a-z0-9_]{0,31}\z")]
private static partial Regex IdRegex();
public static bool IsValidId(string? id) => id is not null && IdRegex().IsMatch(id);
/// <summary>Formulaires d'origine (révision 0).</summary>
public static FeedbackFormsDocument Defaults() => new() { Revision = 0, Forms = FeedbackForms.All.ToList() };
private static readonly JsonSerializerOptions FileOptions = new(ApiJson.Options) { WriteIndented = true };
private static readonly JsonSerializerOptions CanonicalOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>Lit feedback-forms.json (null : vide).</summary>
/// <exception cref="JsonException">JSON illisible.</exception>
public static FeedbackFormsDocument? Parse(ReadOnlySpan<byte> json) => JsonSerializer.Deserialize<FeedbackFormsDocument>(json, ApiJson.Options);
public static string Serialize(FeedbackFormsDocument document) => JsonSerializer.Serialize(document, FileOptions);
/// <summary>
/// Condensé signé : format, version, révision et formulaires (tels quels, dans l'ordre ; valeurs vides ramenées à
/// une forme unique). La date de publication et la signature n'en font pas partie.
/// </summary>
public static string Digest(FeedbackFormsDocument document)
{
var forms = (document.Forms ?? new List<FeedbackForm>()).Where(f => f is not null).Select(f => f with
{
CustomIcon = string.IsNullOrEmpty(f.CustomIcon) ? null : f.CustomIcon,
Fields = (f.Fields ?? Array.Empty<FeedbackField>()).Where(x => x is not null).Select(x => x with
{
Hint = string.IsNullOrEmpty(x.Hint) ? null : x.Hint,
Options = x.Options is { Count: > 0 } options ? options.ToList() : null
}).ToList()
}).ToList();
var canonical = new CanonicalForms(document.Format ?? "", document.Version, document.Revision, forms);
return ContentSigning.Sha256(JsonSerializer.SerializeToUtf8Bytes(canonical, CanonicalOptions));
}
private sealed record CanonicalForms(string Format, int Version, long Revision, List<FeedbackForm> Forms);
/// <summary>
/// Contrôle complet d'un document publié (révision 1 au moins). Les erreurs, en français, désignent le type et le
/// champ concernés ; elles sont ajoutées à <paramref name="errors"/>.
/// </summary>
public static bool Validate(FeedbackFormsDocument? document, List<string> errors)
{
var count = errors.Count;
if (document is null)
{
errors.Add("Formulaires absents.");
return false;
}
if (document.Format != FeedbackFormsDocument.FormatName || document.Version != FeedbackFormsDocument.CurrentVersion)
errors.Add($"Format de formulaires non pris en charge ({document.Format} v{document.Version}).");
if (document.Revision < 1) errors.Add("Révision des formulaires invalide (1 au moins).");
var forms = document.Forms ?? new List<FeedbackForm>();
if (forms.Count is 0 or > MaxForms || forms.Any(f => f is null))
{
errors.Add($"Les formulaires doivent comprendre de 1 à {MaxForms} types de demande.");
return false;
}
var kinds = new HashSet<string>(StringComparer.Ordinal);
foreach (var form in forms)
{
if (!IsValidId(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant invalide (minuscules, chiffres, « _ »).");
else if (!kinds.Add(form.Kind)) errors.Add($"Type « {form.Label} » : identifiant « {form.Kind} » en double.");
}
if (forms.All(f => f.Hidden)) errors.Add("Au moins un type de demande doit rester proposé aux joueurs.");
foreach (var form in forms) ValidateForm(form, errors);
if (errors.Count == count && Encoding.UTF8.GetByteCount(Serialize(document)) > MaxBytes)
errors.Add($"Formulaires trop volumineux (au plus {MaxBytes / 1024} Ko).");
return errors.Count == count;
}
private static void ValidateForm(FeedbackForm form, List<string> errors)
{
var where = $"Type « {form.Label} »";
CheckLine(form.Label, MaxFormLabelLength, required: true, $"{where} : nom", errors);
CheckLine(form.Description, MaxDescriptionLength, required: true, $"{where} : description", errors);
CheckLine(form.TitleHint, MaxTitleHintLength, required: false, $"{where} : exemple de titre", errors);
if (!Icons.Contains(form.IconKey, StringComparer.Ordinal)) errors.Add($"{where} : icône inconnue.");
if (form.CustomIcon is not null && FeedbackIcons.CheckCustom(form.CustomIcon) is { } iconError) errors.Add($"{where} : {iconError}");
var fields = form.Fields ?? Array.Empty<FeedbackField>();
if (fields.Count > MaxFields) errors.Add($"{where} : {MaxFields} champs au plus.");
if (fields.Any(f => f is null))
{
errors.Add($"{where} : champ vide.");
return;
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var label = $"{where}, champ « {field.Label} »";
if (!IsValidId(field.Id)) errors.Add($"{label} : identifiant invalide (minuscules, chiffres, « _ », 32 caractères au plus).");
else if (!ids.Add(field.Id)) errors.Add($"{label} : identifiant « {field.Id} » en double.");
CheckLine(field.Label, MaxFieldLabelLength, required: true, $"{label} : libellé", errors);
CheckHint(field.Hint, $"{label} : aide", errors);
if (!Enum.IsDefined(field.Kind)) errors.Add($"{label} : type de champ inconnu.");
var minLength = field.Kind == FeedbackFieldKind.Multiline ? MinMultilineLength : 1;
var maxLength = field.Kind switch
{
FeedbackFieldKind.Multiline => FeedbackRules.MaxMultilineLength,
FeedbackFieldKind.Text => MaxTextLength,
_ => FeedbackRules.MaxMultilineLength
};
if (field.MaxLength < minLength || field.MaxLength > maxLength)
errors.Add($"{label} : longueur maximale de {minLength} à {maxLength} caractères.");
var options = field.Options ?? Array.Empty<FeedbackOption>();
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes)
{
if (options.Count < MinOptions || options.Count > MaxOptions)
errors.Add($"{label} : {MinOptions} à {MaxOptions} choix.");
var optionIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in options)
{
if (option is null || !IsValidId(option.Id) || !optionIds.Add(option.Id))
{
errors.Add($"{label} : identifiant de choix invalide ou en double.");
continue;
}
CheckLine(option.Label, MaxOptionLabelLength, required: true, $"{label} : choix « {option.Label} »", errors);
}
}
else if (options.Count > 0)
{
errors.Add($"{label} : seules les listes et les cases à cocher ont des choix.");
}
if (field.Kind == FeedbackFieldKind.Rating && !RatingSizes.Contains(field.RatingMax))
errors.Add($"{label} : nombre d'étoiles invalide ({string.Join(", ", RatingSizes)}).");
if (field.ShowInList && field.Kind is not (FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo))
errors.Add($"{label} : seules une liste, une note ou une case oui/non peuvent être affichées en aperçu.");
}
if (fields.Count(f => f.ShowInList) > 1) errors.Add($"{where} : un seul champ peut être affiché en aperçu dans la liste.");
}
/// <summary>Texte d'une ligne déjà propre (tel qu'il sera affiché), sans lien.</summary>
private static void CheckLine(string? value, int maxLength, bool required, string what, List<string> errors)
{
if (string.IsNullOrEmpty(value))
{
if (required) errors.Add($"{what} : obligatoire.");
return;
}
if (ApiText.CleanLine(value, maxLength) != value)
errors.Add($"{what} : {maxLength} caractères au plus, sur une ligne, sans espace en trop ni caractère spécial invisible.");
else if (FeedbackLinks.Contains(value))
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
}
/// <summary>Aide d'un champ : quelques lignes déjà propres, sans lien.</summary>
private static void CheckHint(string? value, string what, List<string> errors)
{
if (string.IsNullOrEmpty(value)) return;
var clean = FeedbackRules.CleanMultiline(value, MaxHintLength, out var problem);
if (problem is not null || clean != value || value.Count(c => c == '\n') + 1 > MaxHintLines)
errors.Add($"{what} : {MaxHintLength} caractères et {MaxHintLines} lignes au plus, sans caractère spécial invisible.");
else if (FeedbackLinks.Contains(value))
errors.Add($"{what} : les liens et adresses ne sont pas autorisés.");
}
/// <summary>
/// Mots qui laissent penser qu'un champ demande des données personnelles ou secrètes : AVERTISSEMENT seulement
/// dans le menu (pas un refus). Les demandes sont anonymes : ne rien demander qui identifie le joueur.
/// </summary>
public static IReadOnlyList<string> PersonalDataWords { get; } = new[]
{
"mot de passe", "password", "e-mail", "email", "courriel", "adresse", "téléphone", "telephone", "nom complet",
"date de naissance", "carte", "iban", "identifiant de connexion", "pseudo steam", "compte"
};
}
@@ -0,0 +1,241 @@
using System.Buffers.Binary;
using System.IO.Compression;
namespace GameLauncher.Contracts.Feedback;
/// <summary>
/// Icônes des types de demande : l'une des quatre icônes d'origine, ou une icône importée depuis un fichier .ico
/// (OBLIGATOIREMENT) contenant une image de 64 × 64 pixels.
/// • L'import (menu d'administration) lit le fichier .ico sans bibliothèque : en-tête et répertoire contrôlés, image
/// 64 × 64 au format PNG (revérifiée) ou bitmap 32 ou 24 bits (avec son masque de transparence) ; tout le reste est
/// refusé. L'image est réécrite en PNG 64 × 64 neuf, sans aucune métadonnée.
/// • Le PNG obtenu est intégré (base64) aux formulaires SIGNÉS : le serveur et les launchers le revérifient
/// (<see cref="CheckCustom"/> : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé).
/// </summary>
public static class FeedbackIcons
{
/// <summary>Côté de l'image attendue dans le fichier .ico et de l'icône publiée.</summary>
public const int Size = 64;
/// <summary>Taille d'un fichier .ico accepté à l'import.</summary>
public const int MaxIcoBytes = 1024 * 1024;
/// <summary>Taille de l'icône publiée (PNG).</summary>
public const int MaxPngBytes = 20 * 1024;
/// <summary>Les quatre icônes d'origine des types de demande (ressources « Icon.&lt;clé&gt; » du launcher).</summary>
public static readonly IReadOnlyList<string> DefaultIcons = new[] { "Star", "Lightbulb", "Bug", "Forum" };
public static string DefaultIconLabel(string? key) => key switch
{
"Star" => "Étoile",
"Lightbulb" => "Ampoule",
"Bug" => "Insecte (bug)",
"Forum" => "Bulles de discussion",
_ => key ?? ""
};
private static readonly byte[] PngSignature = { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
/// <summary>
/// Convertit un fichier .ico en icône publiable (PNG 64 × 64 sans métadonnées). Renvoie null si tout va bien, sinon
/// la raison du refus (française, lisible).
/// </summary>
public static string? FromIco(ReadOnlySpan<byte> ico, out byte[]? png)
{
png = null;
if (ico.Length > MaxIcoBytes) return $"Fichier trop volumineux (au plus {MaxIcoBytes / 1024} Ko).";
// ICONDIR : réservé = 0, type = 1 (icône), nombre d'images.
if (ico.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(ico) != 0 || BinaryPrimitives.ReadUInt16LittleEndian(ico[2..]) != 1)
return "Ce fichier n'est pas une icône .ico.";
var count = BinaryPrimitives.ReadUInt16LittleEndian(ico[4..]);
if (count is 0 or > 64 || ico.Length < 6 + count * 16) return "Icône .ico endommagée (répertoire des images).";
var sizes = new List<string>();
for (var i = 0; i < count; i++)
{
var entry = ico.Slice(6 + i * 16, 16);
var width = entry[0] == 0 ? 256 : entry[0];
var height = entry[1] == 0 ? 256 : entry[1];
sizes.Add($"{width} × {height}");
if (width != Size || height != Size) continue;
var length = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]);
var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]);
if (length == 0 || offset < 6 + (uint)count * 16 || (ulong)offset + length > (ulong)ico.Length)
return "Icône .ico endommagée (image 64 × 64 hors du fichier).";
var image = ico.Slice((int)offset, (int)length);
return image.StartsWith(PngSignature) ? FromPng(image, out png) : FromBitmap(image, out png);
}
return $"Le fichier .ico doit contenir une image de {Size} × {Size} pixels (images trouvées : {string.Join(", ", sizes.Distinct())}).";
}
/// <summary>
/// Icône publiée (base64) contrôlée : PNG valide, exactement 64 × 64, 20 Ko au plus, déjà nettoyé (aucune
/// métadonnée). Null si elle est acceptable, sinon la raison du refus.
/// </summary>
public static string? CheckCustom(string? base64)
{
var bytes = Decode(base64);
if (bytes is null) return "Icône importée illisible.";
if (bytes.Length > MaxPngBytes) return $"Icône importée trop volumineuse (au plus {MaxPngBytes / 1024} Ko).";
if (FeedbackImages.Sanitize(bytes, out var info, out var clean) is { } error) return $"Icône importée refusée : {error}";
if (info!.Format != FeedbackImages.Png || info.Width != Size || info.Height != Size)
return $"L'icône importée doit être une image PNG de {Size} × {Size} pixels.";
if (!clean!.AsSpan().SequenceEqual(bytes)) return "Icône importée non nettoyée (métadonnées).";
return null;
}
/// <summary>Octets d'une icône publiée (null : absente ou base64 invalide).</summary>
public static byte[]? Decode(string? base64)
{
if (string.IsNullOrEmpty(base64) || base64.Length > MaxPngBytes * 2) return null;
try
{
return Convert.FromBase64String(base64);
}
catch (FormatException)
{
return null;
}
}
// ------------------------------------------------------------------ Images de l'icône
/// <summary>Image PNG de l'icône : revérifiée et nettoyée (métadonnées retirées), 64 × 64 exigé.</summary>
private static string? FromPng(ReadOnlySpan<byte> image, out byte[]? png)
{
png = null;
if (FeedbackImages.Sanitize(image, out var info, out var clean) is { } error) return $"Image 64 × 64 de l'icône refusée : {error}";
if (info!.Width != Size || info.Height != Size) return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
if (clean!.Length > MaxPngBytes) return $"Image de l'icône trop volumineuse (au plus {MaxPngBytes / 1024} Ko une fois nettoyée).";
png = clean;
return null;
}
/// <summary>
/// Image bitmap de l'icône (BITMAPINFOHEADER, sans compression, 32 ou 24 bits) : pixels lus ligne par ligne (de bas
/// en haut), transparence prise du canal alpha (32 bits) ou du masque 1 bit, puis réécrits en PNG neuf.
/// </summary>
private static string? FromBitmap(ReadOnlySpan<byte> image, out byte[]? png)
{
png = null;
if (image.Length < 40 || BinaryPrimitives.ReadUInt32LittleEndian(image) != 40)
return "Image de l'icône dans un format non pris en charge (PNG ou bitmap 24/32 bits seulement).";
var width = BinaryPrimitives.ReadInt32LittleEndian(image[4..]);
var height = BinaryPrimitives.ReadInt32LittleEndian(image[8..]);
var planes = BinaryPrimitives.ReadUInt16LittleEndian(image[12..]);
var bits = BinaryPrimitives.ReadUInt16LittleEndian(image[14..]);
var compression = BinaryPrimitives.ReadUInt32LittleEndian(image[16..]);
if (width != Size || height != Size * 2 || planes != 1)
return $"L'image de l'icône doit faire {Size} × {Size} pixels.";
if (compression != 0 || bits is not (32 or 24))
return "Image de l'icône en 256 couleurs ou moins : enregistrez l'icône en couleurs 24 ou 32 bits (ou PNG).";
var stride = (Size * bits / 8 + 3) & ~3;
var maskStride = (Size + 31) / 32 * 4;
var pixels = image[40..];
if (pixels.Length < stride * Size) return "Icône .ico endommagée (image incomplète).";
var mask = pixels.Length >= stride * Size + maskStride * Size ? pixels.Slice(stride * Size, maskStride * Size) : ReadOnlySpan<byte>.Empty;
var rgba = new byte[Size * Size * 4];
var hasAlpha = false;
for (var y = 0; y < Size; y++)
{
var row = pixels.Slice((Size - 1 - y) * stride, stride); // lignes de bas en haut
for (var x = 0; x < Size; x++)
{
var source = x * (bits / 8);
var target = (y * Size + x) * 4;
rgba[target] = row[source + 2];
rgba[target + 1] = row[source + 1];
rgba[target + 2] = row[source];
rgba[target + 3] = bits == 32 ? row[source + 3] : (byte)255;
if (bits == 32 && row[source + 3] != 0) hasAlpha = true;
}
}
// Pas de canal alpha utilisable : transparence donnée par le masque (bit à 1 = transparent).
if (!hasAlpha && !mask.IsEmpty)
{
for (var y = 0; y < Size; y++)
{
var row = mask.Slice((Size - 1 - y) * maskStride, maskStride);
for (var x = 0; x < Size; x++)
rgba[(y * Size + x) * 4 + 3] = (row[x / 8] & (0x80 >> (x % 8))) != 0 ? (byte)0 : (byte)255;
}
}
else if (!hasAlpha)
{
for (var i = 3; i < rgba.Length; i += 4) rgba[i] = 255;
}
var encoded = EncodePng(rgba);
// Contrôle final avec les règles des images (comme toute icône publiée).
if (FeedbackImages.Sanitize(encoded, out _, out var clean) is { } error) return $"Conversion de l'icône impossible : {error}";
if (clean!.Length > MaxPngBytes) return $"Icône trop détaillée (au plus {MaxPngBytes / 1024} Ko une fois convertie en PNG).";
png = clean;
return null;
}
/// <summary>PNG 64 × 64 RVBA (8 bits par canal, sans entrelacement, filtre « Sub »), sans aucun bloc facultatif.</summary>
private static byte[] EncodePng(byte[] rgba)
{
var raw = new byte[Size * (1 + Size * 4)];
for (var y = 0; y < Size; y++)
{
var rowStart = y * (1 + Size * 4);
raw[rowStart] = 1; // Sub : meilleure compression des aplats
for (var i = 0; i < Size * 4; i++)
{
var value = rgba[y * Size * 4 + i];
var left = i >= 4 ? rgba[y * Size * 4 + i - 4] : (byte)0;
raw[rowStart + 1 + i] = unchecked((byte)(value - left));
}
}
byte[] compressed;
using (var buffer = new MemoryStream())
{
using (var zlib = new ZLibStream(buffer, CompressionLevel.SmallestSize, leaveOpen: true)) zlib.Write(raw);
compressed = buffer.ToArray();
}
using var png = new MemoryStream();
png.Write(PngSignature);
var header = new byte[13];
BinaryPrimitives.WriteInt32BigEndian(header, Size);
BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), Size);
header[8] = 8; // bits par canal
header[9] = 6; // RVBA
WriteChunk(png, "IHDR", header);
WriteChunk(png, "IDAT", compressed);
WriteChunk(png, "IEND", Array.Empty<byte>());
return png.ToArray();
}
private static void WriteChunk(Stream stream, string type, byte[] body)
{
Span<byte> number = stackalloc byte[4];
BinaryPrimitives.WriteInt32BigEndian(number, body.Length);
stream.Write(number);
var typeBytes = System.Text.Encoding.ASCII.GetBytes(type);
stream.Write(typeBytes);
stream.Write(body);
BinaryPrimitives.WriteUInt32BigEndian(number, Crc32(typeBytes, body));
stream.Write(number);
}
private static readonly uint[] CrcTable = Enumerable.Range(0, 256).Select(n =>
{
var c = (uint)n;
for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
return c;
}).ToArray();
private static uint Crc32(ReadOnlySpan<byte> type, ReadOnlySpan<byte> body)
{
var c = 0xFFFFFFFFu;
foreach (var b in type) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
foreach (var b in body) c = CrcTable[(c ^ b) & 0xFF] ^ (c >> 8);
return c ^ 0xFFFFFFFFu;
}
}
@@ -25,6 +25,10 @@ public static partial class FeedbackRules
public const int MaxAdminNoteLength = 500;
/// <summary>Valeurs d'une case oui/non.</summary>
public const string Yes = "yes";
public const string No = "no";
/// <summary>Identifiant interne d'une demande : 128 bits en hexadécimal minuscule.</summary>
[GeneratedRegex(@"\A[0-9a-f]{32}\z")]
private static partial Regex IdRegex();
@@ -64,11 +68,16 @@ public static partial class FeedbackRules
return $"FB-{new string(chars, 0, 4)}-{new string(chars, 4, 4)}";
}
/// <summary>Contrôle complet d'une demande avec les formulaires d'origine (révision 0).</summary>
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors) =>
Validate(submission, FeedbackForms.Find(submission?.Kind), errors);
/// <summary>
/// Contrôle complet d'une demande. Renvoie la demande nettoyée (textes normalisés, champs vides retirés,
/// défi conservé) ou null avec les erreurs (françaises, lisibles par le joueur).
/// Contrôle complet d'une demande avec le formulaire de son type (celui de la révision annoncée). Renvoie la
/// demande nettoyée (textes normalisés, champs vides retirés, défi conservé) ou null avec les erreurs
/// (françaises, lisibles par le joueur).
/// </summary>
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, ICollection<string> errors)
public static FeedbackSubmission? Validate(FeedbackSubmission? submission, FeedbackForm? form, ICollection<string> errors)
{
if (submission is null)
{
@@ -78,12 +87,14 @@ public static partial class FeedbackRules
if (!Catalog.CatalogRules.IsValidGameId(submission.GameId)) errors.Add("Jeu inconnu.");
if (submission.FormVersion != FeedbackForms.CurrentVersion)
errors.Add("Formulaire d'une autre version du launcher : mettez le launcher à jour.");
var form = FeedbackForms.Find(submission.Kind);
if (form is null)
if (submission.FormRevision < 0) errors.Add("Révision des formulaires invalide.");
if (form is null || !string.Equals(form.Kind, submission.Kind, StringComparison.Ordinal))
{
errors.Add("Type de demande inconnu.");
return null;
}
if (!form.AllowAttachments && submission.AttachmentCount > 0)
errors.Add("Les captures d'écran ne sont pas acceptées pour ce type de demande.");
var title = ApiText.CleanLine(submission.Title, MaxTitleLength);
if (title is null || title.Length < MinTitleLength)
@@ -134,7 +145,8 @@ public static partial class FeedbackRules
return null;
}
var valid = field.Kind == FeedbackFieldKind.Rating
? raw is "1" or "2" or "3" or "4" or "5"
? raw.Length <= 2 && raw.All(char.IsAsciiDigit) && raw[0] != '0' &&
int.Parse(raw, CultureInfo.InvariantCulture) <= field.RatingMax
: field.Choices.Any(o => string.Equals(o.Id, raw, StringComparison.Ordinal));
if (!valid)
{
@@ -143,6 +155,46 @@ public static partial class FeedbackRules
}
return raw;
case FeedbackFieldKind.Checkboxes:
if (string.IsNullOrEmpty(raw))
{
if (field.Required) errors.Add($"{field.Label} : cochez au moins un choix.");
return null;
}
var ids = raw.Split(',');
if (ids.Length > field.Choices.Count || ids.Distinct(StringComparer.Ordinal).Count() != ids.Length ||
ids.Any(id => !field.Choices.Any(o => string.Equals(o.Id, id, StringComparison.Ordinal))))
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
// Ordre des options du formulaire : une même sélection donne toujours la même valeur.
return string.Join(',', field.Choices.Select(o => o.Id).Where(id => ids.Contains(id, StringComparer.Ordinal)));
case FeedbackFieldKind.YesNo:
if (string.IsNullOrEmpty(raw)) raw = No;
if (raw is not (Yes or No))
{
errors.Add($"{field.Label} : valeur invalide.");
return null;
}
if (field.Required && raw != Yes) errors.Add($"{field.Label} : case à cocher obligatoire.");
return raw;
case FeedbackFieldKind.Text:
if (string.IsNullOrWhiteSpace(raw))
{
if (field.Required) errors.Add($"{field.Label} : champ obligatoire.");
return null;
}
var line = ApiText.CleanLine(raw, field.MaxLength);
if (line is null)
{
errors.Add($"{field.Label} : {field.MaxLength} caractères au plus, sur une ligne, sans caractère spécial invisible.");
return null;
}
return line;
default:
var text = CleanMultiline(raw, field.MaxLength, out var problem);
if (problem is not null)
@@ -34,9 +34,12 @@ public static class AdminPermissions
/// <summary>Traitement des demandes des joueurs : statut, note interne, suppression.</summary>
public const string FeedbackManage = "feedback.manage";
/// <summary>Formulaires des demandes (champs proposés aux joueurs) : modification et publication signée.</summary>
public const string FeedbackForms = "feedback.forms";
public static readonly IReadOnlyList<string> All = new[]
{
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage
AdminsManage, AuditRead, GamesEdit, ChangelogEdit, ReleasesPublish, FilesManage, FeedbackRead, FeedbackManage, FeedbackForms
};
/// <summary>Permission intégrée (vérifiée par le code : jamais supprimable).</summary>
@@ -53,6 +56,7 @@ public static class AdminPermissions
FilesManage => "Fichiers du serveur",
FeedbackRead => "Demandes des joueurs (lecture)",
FeedbackManage => "Demandes des joueurs (traitement)",
FeedbackForms => "Formulaires des demandes",
_ => null
};
@@ -70,6 +74,8 @@ public static class AdminPermissions
"des données personnelles. Chaque consultation est inscrite au journal d'audit.",
FeedbackManage => "Changer le statut des demandes des joueurs, écrire une note interne, supprimer une demande " +
"(à donner avec la lecture).",
FeedbackForms => "Modifier, masquer et publier les formulaires du bouton « Aide » (champs proposés aux joueurs). " +
"Publication signée : la clé doit avoir l'autorisation « formulaires » du trousseau.",
_ => null
};
}
@@ -27,7 +27,7 @@ public static class AdminRoles
{
AdminPermissions.AuditRead, AdminPermissions.GamesEdit,
AdminPermissions.ChangelogEdit, AdminPermissions.ReleasesPublish, AdminPermissions.FilesManage,
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage
AdminPermissions.FeedbackRead, AdminPermissions.FeedbackManage, AdminPermissions.FeedbackForms
},
[Editor] = new[] { AdminPermissions.GamesEdit, AdminPermissions.ChangelogEdit }
};
@@ -28,12 +28,21 @@ public static class ContentKinds
{
public const string Manifest = "manifest";
public const string Catalog = "catalog";
public const string FeedbackForms = "feedback-forms";
/// <summary>Identifiant signé d'un catalogue (il n'y en a qu'un).</summary>
public const string CatalogId = "catalog";
/// <summary>Identifiant signé des formulaires des demandes (communs à tous les jeux).</summary>
public const string FeedbackFormsId = "feedback-forms";
/// <summary>Autorisation du trousseau requise pour signer ce type de contenu.</summary>
public static string ScopeOf(string kind) => kind == Manifest ? SigningScopes.Releases : SigningScopes.Catalog;
public static string ScopeOf(string kind) => kind switch
{
Manifest => SigningScopes.Releases,
FeedbackForms => SigningScopes.Forms,
_ => SigningScopes.Catalog
};
}
/// <summary>Autorisations d'une clé du trousseau.</summary>
@@ -45,7 +54,17 @@ public static class SigningScopes
/// <summary>Signer le catalogue (noms, liens, images affichés) : permission games.edit.</summary>
public const string Catalog = "catalog";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog };
/// <summary>Signer les formulaires des demandes (champs affichés aux joueurs) : permission feedback.forms.</summary>
public const string Forms = "forms";
public static readonly IReadOnlyList<string> All = new[] { Releases, Catalog, Forms };
/// <summary>
/// Autorisation bien formée : une autorisation inconnue de cette version (ajoutée par une version plus récente) est
/// acceptée dans le trousseau mais n'autorise rien ici (<see cref="ContentSigning.Verify"/> exige l'autorisation exacte).
/// </summary>
public static bool IsWellFormed(string? scope) =>
scope is { Length: >= 1 and <= 32 } && scope[0] is >= 'a' and <= 'z' && scope.All(c => c is >= 'a' and <= 'z' or >= '0' and <= '9' or '-');
/// <summary>Autorisations qu'apportent les permissions d'un administrateur.</summary>
public static IReadOnlyList<string> FromPermissions(IEnumerable<string> permissions)
@@ -54,6 +73,7 @@ public static class SigningScopes
var scopes = new List<string>();
if (set.Contains(AdminPermissions.ReleasesPublish)) scopes.Add(Releases);
if (set.Contains(AdminPermissions.GamesEdit)) scopes.Add(Catalog);
if (set.Contains(AdminPermissions.FeedbackForms)) scopes.Add(Forms);
return scopes;
}
@@ -61,6 +81,7 @@ public static class SigningScopes
{
Releases => "versions du jeu",
Catalog => "catalogue",
Forms => "formulaires des demandes",
_ => scope
};
}
@@ -194,7 +215,12 @@ public static class ContentSigning
private sealed record CanonicalCatalog(string Format, int Version, CatalogLauncher? Launcher, SortedDictionary<string, CatalogGame> Games);
/// <summary>Identifiant signé : jeu en minuscules, ou « catalog ».</summary>
public static string SignedId(string kind, string id) => kind == ContentKinds.Catalog ? ContentKinds.CatalogId : id.ToLowerInvariant();
public static string SignedId(string kind, string id) => kind switch
{
ContentKinds.Catalog => ContentKinds.CatalogId,
ContentKinds.FeedbackForms => ContentKinds.FeedbackFormsId,
_ => id.ToLowerInvariant()
};
/// <summary>Date de signature ramenée à la seconde (UTC), comme dans le message signé.</summary>
public static DateTimeOffset Truncate(DateTimeOffset value) => DateTimeOffset.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
@@ -127,8 +127,9 @@ public static class Keyring
}
if (string.IsNullOrWhiteSpace(key.Name) || key.Name.Length > MaxNameLength || !IsDisplaySafe(key.Name))
errors.Add($"Clé {label} : nom absent, trop long ou invalide.");
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.All.Contains(s, StringComparer.Ordinal)))
errors.Add($"Clé {label} : autorisations absentes ou inconnues.");
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : acceptée, sans effet ici.
if (key.Scopes.Count == 0 || key.Scopes.Any(s => !SigningScopes.IsWellFormed(s)))
errors.Add($"Clé {label} : autorisations absentes ou invalides.");
}
return errors.Count == count;
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Models;
using GameLauncher.Core.Utils;
@@ -190,6 +191,37 @@ public sealed class ContentTrustService
}
}
/// <summary>
/// Vérifie les formulaires des demandes téléchargés (feedback-forms.json) : clé du trousseau autorisée « forms »,
/// signature valide, pas plus anciens que les derniers acceptés. Faux : à ignorer (formulaires précédents).
/// </summary>
public async Task<bool> VerifyFeedbackFormsAsync(FeedbackFormsDocument forms, CancellationToken ct = default)
{
if (Mode == ContentSigningMode.Off) return true;
try
{
await VerifyAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms), forms.Signature,
$"Formulaires des demandes (révision {forms.Revision})", ct).ConfigureAwait(false);
return true;
}
catch (ContentSecurityException)
{
return false;
}
}
/// <summary>Revérifie les formulaires en cache, sans réseau, avec le dernier trousseau vérifié (fichier modifié sur le disque : ignoré).</summary>
public bool VerifyCachedFeedbackForms(FeedbackFormsDocument forms)
{
if (Mode == ContentSigningMode.Off) return true;
var what = $"Formulaires des demandes en cache (révision {forms.Revision})";
var keyring = LoadState().Keyring;
if (keyring is null) return Report(what, "aucun trousseau vérifié en cache");
var check = ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(forms),
forms.Signature, keyring);
return check.Valid || Report(what, check.Reason);
}
/// <summary>
/// Revérifie le catalogue en cache au démarrage, sans réseau, avec le dernier trousseau vérifié : en mode
/// « require », un catalogue en cache non signé (écrit avant l'activation, ou modifié sur le disque) est ignoré.
@@ -503,6 +503,7 @@ public static class AuditTexts
"feedback.update" => "Demande de joueur traitée (statut ou note)",
"feedback.delete" => "Demande de joueur supprimée",
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
"feedback.forms" => "Formulaires des demandes publiés",
"files.delete" => "Fichier du serveur supprimé",
"files.prune" => "Dossier « objects » nettoyé",
"request.signature" => "Requête signée refusée",
@@ -4,8 +4,9 @@ namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Module « Demandes des joueurs » : page de lecture et de traitement des demandes envoyées depuis le bouton « Aide »
/// (permission feedback.read ; statut, note et suppression : feedback.manage). Le formulaire des joueurs est dans
/// GameLauncher.Modules.Feedback.
/// (permission feedback.read ; statut, note et suppression : feedback.manage), et page « Formulaires des demandes »
/// (feedback.forms : types de demande et champs proposés aux joueurs, publication signée). Le formulaire des joueurs
/// est dans GameLauncher.Modules.Feedback.
/// </summary>
public sealed class FeedbackAdminModule : ILauncherModule
{
@@ -13,7 +14,8 @@ public sealed class FeedbackAdminModule : ILauncherModule
public string DisplayName => "Demandes des joueurs";
public void Register(IModuleRegistry registry) =>
public void Register(IModuleRegistry registry)
{
registry.AddAdminPanel(new AdminPanelDescriptor(
FeedbackPanelViewModel.PanelId,
"Demandes des joueurs",
@@ -22,4 +24,13 @@ public sealed class FeedbackAdminModule : ILauncherModule
AdminPermissions.FeedbackRead,
Order: 15,
context => new FeedbackPanelViewModel(context)));
registry.AddAdminPanel(new AdminPanelDescriptor(
FeedbackFormsPanelViewModel.PanelId,
"Formulaires des demandes",
"Les quatre types de demande du bouton « Aide » et leurs champs, communs à tous les jeux : modifier, masquer, publier (signé).",
"TextBoxOutline",
AdminPermissions.FeedbackForms,
Order: 16,
context => new FeedbackFormsPanelViewModel(context)));
}
}
@@ -0,0 +1,533 @@
using System.Collections.ObjectModel;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules.Admin.Catalog;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>Type de champ proposé par « Ajouter un champ ».</summary>
public sealed class FeedbackFieldKindChoice
{
public FeedbackFieldKindChoice(FeedbackFieldKind kind, Action<FeedbackFieldKind> add)
{
Kind = kind;
Label = FeedbackFieldEditor.KindLabel(kind);
IconKey = FeedbackFieldEditor.KindIcon(kind);
AddCommand = new RelayCommand(() => add(kind));
}
public FeedbackFieldKind Kind { get; }
public string Label { get; }
public string IconKey { get; }
public RelayCommand AddCommand { get; }
}
/// <summary>Choix d'une liste déroulante ou d'une case à cocher : identifiant fixe (clé des valeurs envoyées), libellé modifiable.</summary>
public sealed class FeedbackOptionEditor : ObservableObject
{
private readonly Action _changed;
private string _label;
public FeedbackOptionEditor(string id, string label, Action changed, Action<FeedbackOptionEditor, int> move, Action<FeedbackOptionEditor> remove)
{
Id = id;
_label = label;
_changed = changed;
MoveUpCommand = new RelayCommand(() => move(this, -1));
MoveDownCommand = new RelayCommand(() => move(this, 1));
RemoveCommand = new RelayCommand(() => remove(this));
}
public string Id { get; }
public string Label
{
get => _label;
set
{
if (SetProperty(ref _label, value ?? "")) _changed();
}
}
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public FeedbackOption Build() => new(Id, ApiText.CleanLine(_label, FeedbackFormRules.MaxOptionLabelLength) ?? _label);
}
/// <summary>
/// Champ d'un formulaire en cours de modification. Son identifiant (clé des valeurs envoyées par les joueurs) et son
/// type sont fixés à la création : changer de type revient à retirer le champ et à en ajouter un autre, pour que les
/// demandes déjà reçues restent lisibles.
/// </summary>
public sealed class FeedbackFieldEditor : ObservableObject
{
private readonly Action _changed;
private string _label;
private string _hint;
private bool _required;
private int _maxLength;
private Choice _ratingMax;
private bool _showInList;
private bool _isExpanded;
private int _optionCounter;
public FeedbackFieldEditor(FeedbackField field, bool isNew, Action changed, Action<FeedbackFieldEditor, int> move,
Action<FeedbackFieldEditor> remove)
{
Id = field.Id;
Kind = field.Kind;
IsNew = isNew;
_changed = changed;
_label = field.Label;
_hint = field.Hint ?? "";
_required = field.Required;
_maxLength = field.MaxLength;
RatingSizes = FeedbackFormRules.RatingSizes.Select(n => new Choice(n.ToString(System.Globalization.CultureInfo.InvariantCulture), $"{n} étoiles")).ToList();
_ratingMax = RatingSizes.FirstOrDefault(c => c.Value == field.RatingMax.ToString(System.Globalization.CultureInfo.InvariantCulture)) ?? RatingSizes[1];
_showInList = field.ShowInList;
_isExpanded = isNew;
foreach (var option in field.Choices) Options.Add(NewOption(option.Id, option.Label));
_optionCounter = field.Choices.Count;
Options.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(Summary));
_changed();
};
MoveUpCommand = new RelayCommand(() => move(this, -1));
MoveDownCommand = new RelayCommand(() => move(this, 1));
RemoveCommand = new RelayCommand(() => remove(this));
ToggleCommand = new RelayCommand(() => IsExpanded = !IsExpanded);
AddOptionCommand = new RelayCommand(AddOption, () => Options.Count < FeedbackFormRules.MaxOptions);
}
public string Id { get; }
public FeedbackFieldKind Kind { get; }
/// <summary>Champ ajouté depuis la dernière publication.</summary>
public bool IsNew { get; }
public string KindText => KindLabel(Kind);
public string KindIconKey => KindIcon(Kind);
public bool HasOptions => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes;
public bool HasMaxLength => Kind is FeedbackFieldKind.Multiline or FeedbackFieldKind.Text;
public bool IsRating => Kind == FeedbackFieldKind.Rating;
/// <summary>Seuls une liste, une note ou une case oui/non peuvent servir d'aperçu dans la liste des demandes.</summary>
public bool CanShowInList => Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Rating or FeedbackFieldKind.YesNo;
public string RequiredText => Kind == FeedbackFieldKind.YesNo ? "Obligatoire (la case doit être cochée)" : "Obligatoire";
public string MaxLengthText => Kind == FeedbackFieldKind.Text
? $"Longueur maximale (1 à {FeedbackFormRules.MaxTextLength})"
: $"Longueur maximale ({FeedbackFormRules.MinMultilineLength} à {FeedbackRules.MaxMultilineLength})";
public string Label
{
get => _label;
set
{
if (!SetProperty(ref _label, value ?? "")) return;
OnPropertyChanged(nameof(Title));
_changed();
}
}
/// <summary>Titre de la ligne repliée (libellé, sinon « Champ sans libellé »).</summary>
public string Title => string.IsNullOrWhiteSpace(_label) ? "Champ sans libellé" : _label;
public string Hint
{
get => _hint;
set
{
if (SetProperty(ref _hint, value ?? "")) _changed();
}
}
public bool Required
{
get => _required;
set
{
if (!SetProperty(ref _required, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public int MaxLength
{
get => _maxLength;
set
{
if (SetProperty(ref _maxLength, value)) _changed();
}
}
public IReadOnlyList<Choice> RatingSizes { get; }
public Choice RatingMax
{
get => _ratingMax;
set
{
if (value is null || !SetProperty(ref _ratingMax, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public bool ShowInList
{
get => _showInList;
set
{
if (!SetProperty(ref _showInList, value)) return;
OnPropertyChanged(nameof(Summary));
_changed();
}
}
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
/// <summary>Résumé de la ligne repliée : type, obligatoire, choix, aperçu.</summary>
public string Summary
{
get
{
var parts = new List<string> { KindText };
if (_required) parts.Add("obligatoire");
if (HasOptions) parts.Add($"{Options.Count} choix");
if (IsRating) parts.Add(_ratingMax.Label);
if (_showInList && CanShowInList) parts.Add("aperçu dans la liste");
if (IsNew) parts.Add("nouveau");
return string.Join(" · ", parts);
}
}
public ObservableCollection<FeedbackOptionEditor> Options { get; } = new();
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public RelayCommand ToggleCommand { get; }
public RelayCommand AddOptionCommand { get; }
/// <summary>Champ tel qu'il sera publié (espaces en trop retirés ; le reste est contrôlé par FeedbackFormRules).</summary>
public FeedbackField Build() => new(
Id,
ApiText.CleanLine(_label, FeedbackFormRules.MaxFieldLabelLength) ?? _label,
Kind,
_required,
string.IsNullOrWhiteSpace(_hint) ? null : FeedbackRules.CleanMultiline(_hint, FeedbackFormRules.MaxHintLength, out _) ?? _hint,
HasMaxLength ? _maxLength : FeedbackRules.MaxMultilineLength,
HasOptions ? Options.Select(o => o.Build()).ToList() : null,
IsRating && int.TryParse(_ratingMax.Value, out var max) ? max : 5,
_showInList && CanShowInList);
private void AddOption()
{
var id = NextOptionId();
Options.Add(NewOption(id, $"Choix {Options.Count + 1}"));
AddOptionCommand.RaiseCanExecuteChanged();
}
private string NextOptionId()
{
string id;
do id = $"choix_{++_optionCounter}";
while (Options.Any(o => o.Id == id));
return id;
}
private FeedbackOptionEditor NewOption(string id, string label) => new(id, label, _changed, MoveOption, option =>
{
Options.Remove(option);
AddOptionCommand.RaiseCanExecuteChanged();
});
private void MoveOption(FeedbackOptionEditor option, int delta)
{
var index = Options.IndexOf(option);
var target = index + delta;
if (index < 0 || target < 0 || target >= Options.Count) return;
Options.Move(index, target);
}
public static string KindLabel(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "Zone de texte",
FeedbackFieldKind.Text => "Texte court",
FeedbackFieldKind.Choice => "Liste déroulante",
FeedbackFieldKind.Checkboxes => "Cases à cocher",
FeedbackFieldKind.YesNo => "Case oui/non",
FeedbackFieldKind.Rating => "Étoiles",
_ => kind.ToString()
};
public static string KindIcon(FeedbackFieldKind kind) => kind switch
{
FeedbackFieldKind.Multiline => "TextBoxOutline",
FeedbackFieldKind.Text => "Minus",
FeedbackFieldKind.Choice => "ChevronRight",
FeedbackFieldKind.Checkboxes => "CheckCircle",
FeedbackFieldKind.YesNo => "Check",
FeedbackFieldKind.Rating => "Star",
_ => "Info"
};
}
/// <summary>Actions d'un type de demande qui touchent la liste (ordre, suppression) ou demandent un fichier (icône).</summary>
public sealed record FeedbackFormEditorActions(
Action<FeedbackFormEditor, int> Move,
Action<FeedbackFormEditor> Remove,
Action<FeedbackFormEditor> ImportIcon);
/// <summary>
/// Type de demande en cours de modification (ajouté, supprimé, réordonné, masqué) : présentation (nom, description,
/// icône d'origine ou importée, captures d'écran autorisées…) et champs.
/// </summary>
public sealed class FeedbackFormEditor : ObservableObject
{
private readonly Action _changed;
private readonly HashSet<string> _publishedIds;
private string _label;
private string _description;
private Choice _icon;
private string _titleHint;
private bool _techByDefault;
private bool _hidden;
private bool _allowAttachments;
private string? _customIcon;
private bool _isDirty;
private int _fieldCounter;
/// <param name="isNew">Type ajouté depuis la dernière publication.</param>
public FeedbackFormEditor(FeedbackForm form, Action changed, FeedbackFormEditorActions? actions = null, bool isNew = false)
{
Kind = form.Kind;
IsNew = isNew;
_changed = changed;
_label = form.Label;
_description = form.Description;
Icons = FeedbackFormRules.Icons.Select(i => new Choice(i, FeedbackIcons.DefaultIconLabel(i))).ToList();
_icon = Icons.FirstOrDefault(i => i.Value == form.IconKey) ?? Icons[^1];
_titleHint = form.TitleHint;
_techByDefault = form.TechByDefault;
_hidden = form.Hidden;
_allowAttachments = form.AllowAttachments;
_customIcon = string.IsNullOrEmpty(form.CustomIcon) ? null : form.CustomIcon;
MoveUpCommand = new RelayCommand(() => actions?.Move(this, -1));
MoveDownCommand = new RelayCommand(() => actions?.Move(this, 1));
RemoveCommand = new RelayCommand(() => actions?.Remove(this));
ImportIconCommand = new RelayCommand(() => actions?.ImportIcon(this));
RemoveIconCommand = new RelayCommand(() => SetCustomIcon(null), () => _customIcon is not null);
_publishedIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
foreach (var field in form.Fields) Fields.Add(NewField(field, isNew: false));
Fields.CollectionChanged += (_, _) =>
{
OnPropertyChanged(nameof(FieldsText));
AddFieldsEnabled = Fields.Count < FeedbackFormRules.MaxFields;
_changed();
};
FieldKinds = Enum.GetValues<FeedbackFieldKind>().Select(k => new FeedbackFieldKindChoice(k, AddField)).ToList();
}
public string Kind { get; }
/// <summary>Type ajouté depuis la dernière publication.</summary>
public bool IsNew { get; }
/// <summary>Repère fixe dans la liste, même renommé : nom d'origine, ou « Type ajouté ».</summary>
public string OriginalLabel => FeedbackForms.Find(Kind)?.Label ?? (IsNew ? "Type ajouté" : $"Type « {Kind} »");
public RelayCommand MoveUpCommand { get; }
public RelayCommand MoveDownCommand { get; }
public RelayCommand RemoveCommand { get; }
public RelayCommand ImportIconCommand { get; }
public RelayCommand RemoveIconCommand { get; }
/// <summary>Icône importée (PNG 64 × 64 en base64, issu d'un fichier .ico) ; null : icône d'origine choisie.</summary>
public string? CustomIcon => _customIcon;
public byte[]? CustomIconBytes => FeedbackIcons.Decode(_customIcon);
public bool HasCustomIcon => _customIcon is not null;
/// <summary>Icône d'origine utilisée (liste des quatre icônes active).</summary>
public bool UsesDefaultIcon => _customIcon is null;
public void SetCustomIcon(string? base64)
{
if (_customIcon == base64) return;
_customIcon = base64;
foreach (var name in new[] { nameof(CustomIcon), nameof(CustomIconBytes), nameof(HasCustomIcon), nameof(UsesDefaultIcon) }) OnPropertyChanged(name);
RemoveIconCommand.RaiseCanExecuteChanged();
_changed();
}
/// <summary>Captures d'écran proposées aux joueurs pour ce type (3 au plus, contrôlées et nettoyées).</summary>
public bool AllowAttachments
{
get => _allowAttachments;
set
{
if (SetProperty(ref _allowAttachments, value)) _changed();
}
}
public string Label
{
get => _label;
set
{
if (!SetProperty(ref _label, value ?? "")) return;
OnPropertyChanged(nameof(Title));
_changed();
}
}
public string Title => string.IsNullOrWhiteSpace(_label) ? OriginalLabel : _label;
public string Description
{
get => _description;
set
{
if (SetProperty(ref _description, value ?? "")) _changed();
}
}
public IReadOnlyList<Choice> Icons { get; }
public Choice Icon
{
get => _icon;
set
{
if (value is null || !SetProperty(ref _icon, value)) return;
OnPropertyChanged(nameof(IconKey));
_changed();
}
}
public string IconKey => _icon.Value ?? "Forum";
public string TitleHint
{
get => _titleHint;
set
{
if (SetProperty(ref _titleHint, value ?? "")) _changed();
}
}
public bool TechByDefault
{
get => _techByDefault;
set
{
if (SetProperty(ref _techByDefault, value)) _changed();
}
}
/// <summary>Type masqué : plus proposé aux joueurs (les demandes déjà reçues restent consultables).</summary>
public bool Hidden
{
get => _hidden;
set
{
if (!SetProperty(ref _hidden, value)) return;
OnPropertyChanged(nameof(StateText));
_changed();
}
}
/// <summary>Différent des formulaires publiés (calculé par la page).</summary>
public bool IsDirty
{
get => _isDirty;
set
{
if (SetProperty(ref _isDirty, value)) OnPropertyChanged(nameof(StateText));
}
}
public string StateText => (_hidden ? "Masqué" : "Proposé aux joueurs") + (IsNew ? " · nouveau" : _isDirty ? " · modifié" : "");
public string FieldsText => Fields.Count switch
{
0 => "Aucun champ (titre seulement)",
1 => "1 champ",
var n => $"{n} champs"
};
public ObservableCollection<FeedbackFieldEditor> Fields { get; } = new();
/// <summary>Types proposés par « Ajouter un champ ».</summary>
public IReadOnlyList<FeedbackFieldKindChoice> FieldKinds { get; }
private bool _addFieldsEnabled = true;
public bool AddFieldsEnabled
{
get => _addFieldsEnabled;
private set => SetProperty(ref _addFieldsEnabled, value);
}
/// <summary>Formulaire tel qu'il sera publié.</summary>
public FeedbackForm Build() => new(
Kind,
ApiText.CleanLine(_label, FeedbackFormRules.MaxFormLabelLength) ?? _label,
ApiText.CleanLine(_description, FeedbackFormRules.MaxDescriptionLength) ?? _description,
IconKey,
ApiText.CleanLine(_titleHint, FeedbackFormRules.MaxTitleHintLength) ?? _titleHint,
_techByDefault,
Fields.Select(f => f.Build()).ToList(),
_hidden,
_allowAttachments,
_customIcon);
/// <summary>Nouveau champ (identifiant généré, jamais réutilisé dans ce type), déplié et placé à la fin.</summary>
private void AddField(FeedbackFieldKind kind)
{
if (Fields.Count >= FeedbackFormRules.MaxFields) return;
var prefix = kind switch
{
FeedbackFieldKind.Multiline => "texte",
FeedbackFieldKind.Text => "ligne",
FeedbackFieldKind.Choice => "liste",
FeedbackFieldKind.Checkboxes => "cases",
FeedbackFieldKind.YesNo => "oui_non",
_ => "note"
};
string id;
do id = $"{prefix}_{++_fieldCounter}";
while (Fields.Any(f => f.Id == id) || _publishedIds.Contains(id));
var field = new FeedbackField(id, FeedbackFieldEditor.KindLabel(kind), kind, Required: false,
MaxLength: kind == FeedbackFieldKind.Text ? FeedbackFormRules.DefaultTextLength : FeedbackRules.MaxMultilineLength,
Options: kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes
? new[] { new FeedbackOption("choix_1", "Choix 1"), new FeedbackOption("choix_2", "Choix 2") }
: null);
Fields.Add(NewField(field, isNew: true));
}
private FeedbackFieldEditor NewField(FeedbackField field, bool isNew) =>
new(field, isNew, _changed, MoveField, f => Fields.Remove(f));
private void MoveField(FeedbackFieldEditor field, int delta)
{
var index = Fields.IndexOf(field);
var target = index + delta;
if (index < 0 || target < 0 || target >= Fields.Count) return;
Fields.Move(index, target);
}
}
@@ -0,0 +1,422 @@
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
using GameLauncher.Modules.Feedback;
using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Admin.Feedback;
/// <summary>
/// Page « Formulaires des demandes » (permission feedback.forms) : les types de demande du bouton « Aide » (1 à 8,
/// communs à tous les jeux, dans l'ordre d'affichage aux joueurs) et leurs champs.
/// • à gauche, les types (ajout, ordre, suppression ; proposé / masqué, modifié) ; au centre, la présentation du type
/// (nom, icône d'origine ou importée depuis un fichier .ico 64 × 64, captures d'écran autorisées…), ses champs
/// (ajout, ordre, retrait, libellé, aide, obligatoire, longueur, étoiles, choix) ; à droite, l'aperçu tel que les
/// joueurs le verront ;
/// • contrôle en direct avec les règles des formulaires (FeedbackFormRules : celles du serveur et des launchers) :
/// aucun lien dans les textes, limites ; avertissement (sans refus) si un libellé semble demander des données
/// personnelles — les demandes sont anonymes ;
/// • « Publier » : avertissement si la clé de ce PC n'a pas l'autorisation « formulaires » dans le trousseau publié,
/// formulaires signés par la clé de ce PC, requête signée, révision attendue (409 si quelqu'un a publié entre-temps :
/// rien n'est écrasé) ; « Annuler les modifications », « Revenir aux formulaires d'origine ».
/// </summary>
public sealed class FeedbackFormsPanelViewModel : AdminPanelViewModel
{
public const string PanelId = "feedback.forms";
private FeedbackFormsState? _state;
private string _publishedJson = "";
private Dictionary<string, string> _publishedForms = new();
private FeedbackFormEditor? _selectedForm;
private string? _statusMessage;
private bool _hasConflict;
private bool _isDirty;
private bool _suspendChanges;
private IReadOnlyList<string> _validationErrors = Array.Empty<string>();
private IReadOnlyList<string> _warnings = Array.Empty<string>();
public FeedbackFormsPanelViewModel(IAdminContext context) : base(context)
{
ReloadCommand = new AsyncRelayCommand(ScrollingToTop(ReloadAsync), () => !IsBusy);
PublishCommand = new AsyncRelayCommand(ScrollingToTop(PublishAsync),
() => !IsBusy && _isDirty && !_hasConflict && _validationErrors.Count == 0 && _state is { PublishingEnabled: true });
DiscardCommand = new RelayCommand(Discard, () => !IsBusy && _isDirty);
RestoreOriginalCommand = new RelayCommand(RestoreOriginal, () => !IsBusy && _state is not null);
AddFormCommand = new RelayCommand(AddForm, () => !IsBusy && _state is not null && Forms.Count < FeedbackFormRules.MaxForms);
Forms.CollectionChanged += (_, _) =>
{
AddFormCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(FormsText));
OnEdited();
};
}
/// <summary>Ajouter un type de demande (8 au plus).</summary>
public RelayCommand AddFormCommand { get; }
public string FormsText => $"Types de demande · {Forms.Count} / {FeedbackFormRules.MaxForms}";
public override bool HasSplitLayout => Forms.Count > 0;
public ObservableCollection<FeedbackFormEditor> Forms { get; } = new();
public FeedbackFormEditor? SelectedForm
{
get => _selectedForm;
set
{
if (value is null || !SetProperty(ref _selectedForm, value)) return;
RefreshPreview();
}
}
public AsyncRelayCommand ReloadCommand { get; }
public AsyncRelayCommand PublishCommand { get; }
public RelayCommand DiscardCommand { get; }
public RelayCommand RestoreOriginalCommand { get; }
public string? StatusMessage
{
get => _statusMessage;
private set
{
if (SetProperty(ref _statusMessage, value)) OnPropertyChanged(nameof(HasStatus));
}
}
public bool HasStatus => !string.IsNullOrEmpty(_statusMessage);
public bool HasConflict
{
get => _hasConflict;
private set
{
if (SetProperty(ref _hasConflict, value)) PublishCommand.RaiseCanExecuteChanged();
}
}
/// <summary>Modifications non publiées.</summary>
public bool IsDirty => _isDirty;
public bool IsEditable => !IsBusy;
/// <summary>Publication non configurée sur le serveur (pas de fichier public du trousseau).</summary>
public bool ShowPublishingDisabled => _state is { PublishingEnabled: false };
public string PublishedText
{
get
{
if (_state is null) return "";
var document = _state.Document;
if (document.Revision == 0) return "Formulaires d'origine : rien n'a encore été publié depuis ce menu.";
var when = document.UpdatedAt?.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture);
return $"Révision {document.Revision} publiée" + (when is null ? "" : $" le {when}") +
(_state.UpdatedBy is null ? "" : $" par {_state.UpdatedBy}") + ".";
}
}
public string? PublicUrl => _state?.PublicUrl;
// ------------------------------------------------------------------ Contrôles
/// <summary>Erreurs qui empêchent la publication (règles des formulaires, celles du serveur et des launchers).</summary>
public IReadOnlyList<string> ValidationErrors
{
get => _validationErrors;
private set
{
_validationErrors = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasValidationErrors));
PublishCommand.RaiseCanExecuteChanged();
}
}
public bool HasValidationErrors => _validationErrors.Count > 0;
/// <summary>Avertissements (sans refus) : libellé qui semble demander des données personnelles, type masqué…</summary>
public IReadOnlyList<string> Warnings
{
get => _warnings;
private set
{
_warnings = value;
OnPropertyChanged();
OnPropertyChanged(nameof(HasWarnings));
}
}
public bool HasWarnings => _warnings.Count > 0;
// ------------------------------------------------------------------ Aperçu (mêmes champs que la fenêtre « Aide »)
/// <summary>Champs du type sélectionné tels que les joueurs les verront (saisie possible, rien n'est envoyé).</summary>
public ObservableCollection<FeedbackFieldViewModel> Preview { get; } = new();
public string PreviewTitle => _selectedForm is null ? "" : $"Aperçu · {_selectedForm.Title}";
public string PreviewTitleHint => _selectedForm?.TitleHint ?? "";
public bool PreviewTechByDefault => _selectedForm?.TechByDefault ?? false;
public string PreviewNote => _selectedForm switch
{
{ Hidden: true } => "Ce type est masqué : les joueurs ne le voient pas.",
{ AllowAttachments: true } => "Les joueurs voient aussi les captures d'écran (3 au plus) et les informations techniques.",
_ => "Captures d'écran non proposées pour ce type. Les joueurs voient aussi les informations techniques."
};
// ------------------------------------------------------------------ Chargement
protected override async Task OnActivatedAsync(bool firstTime)
{
if (firstTime) await ReloadAsync();
}
private Task ReloadAsync()
{
if (_isDirty && !Context.Dialogs.Confirm("Abandonner les modifications ?",
"Les formulaires modifiés n'ont pas été publiés : les modifications seront perdues.", "Abandonner"))
return Task.CompletedTask;
return RunAsync(async () =>
{
StatusMessage = null;
Apply(await Context.Session.GetFeedbackFormsAsync());
});
}
/// <summary>Formulaires en vigueur sur le serveur : éditeurs reconstruits, rien de modifié.</summary>
private void Apply(FeedbackFormsState state)
{
_state = state;
HasConflict = false;
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, Snapshot, StringComparer.Ordinal);
_publishedJson = Snapshot(state.Document.Forms);
LoadEditors(state.Document.Forms);
foreach (var name in new[] { nameof(ShowPublishingDisabled), nameof(PublishedText), nameof(PublicUrl) }) OnPropertyChanged(name);
RestoreOriginalCommand.RaiseCanExecuteChanged();
}
private void LoadEditors(IEnumerable<FeedbackForm> forms)
{
var selectedKind = _selectedForm?.Kind;
_suspendChanges = true;
try
{
Forms.Clear();
// Ordre du document : celui affiché aux joueurs.
foreach (var form in forms)
Forms.Add(new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: !_publishedForms.ContainsKey(form.Kind)));
}
finally
{
_suspendChanges = false;
}
_selectedForm = Forms.FirstOrDefault(f => f.Kind == selectedKind) ?? Forms.FirstOrDefault();
OnPropertyChanged(nameof(SelectedForm));
SplitLayoutChanged();
OnEdited();
}
// ------------------------------------------------------------------ Modifications
/// <summary>Une valeur a changé : état « modifié », contrôles et aperçu recalculés.</summary>
private void OnEdited()
{
if (_suspendChanges) return;
OnPropertyChanged(nameof(PreviewNote));
var forms = Forms.Select(f => f.Build()).ToList();
foreach (var (editor, form) in Forms.Zip(forms))
editor.IsDirty = !_publishedForms.TryGetValue(form.Kind, out var published) || published != Snapshot(form);
SetDirty(_state is not null && Snapshot(forms) != _publishedJson);
var errors = new List<string>();
FeedbackFormRules.Validate(new FeedbackFormsDocument { Revision = (_state?.Document.Revision ?? 0) + 1, Forms = forms }, errors);
ValidationErrors = errors.Distinct().ToList();
Warnings = WarningsOf(forms);
RefreshPreview();
}
private void SetDirty(bool value)
{
if (_isDirty == value) return;
_isDirty = value;
OnPropertyChanged(nameof(IsDirty));
PublishCommand.RaiseCanExecuteChanged();
DiscardCommand.RaiseCanExecuteChanged();
}
/// <summary>Avertissements sans refus : données personnelles demandées (les demandes sont anonymes), tout masqué sauf un…</summary>
private static IReadOnlyList<string> WarningsOf(IReadOnlyList<FeedbackForm> forms)
{
var warnings = new List<string>();
foreach (var form in forms)
{
var texts = new[] { form.Label, form.Description, form.TitleHint }
.Concat(form.Fields.SelectMany(f => new[] { f.Label, f.Hint ?? "" }.Concat(f.Choices.Select(o => o.Label))));
var words = FeedbackFormRules.PersonalDataWords
.Where(w => texts.Any(t => t.Contains(w, StringComparison.OrdinalIgnoreCase))).ToList();
if (words.Count > 0)
warnings.Add($"« {form.Label} » mentionne « {string.Join(" », « ", words)} » : les demandes sont anonymes, ne demandez " +
"aucune donnée personnelle (adresse e-mail, mot de passe, identifiant de compte, téléphone…).");
if (!form.Hidden && form.Fields.Count == 0)
warnings.Add($"« {form.Label} » n'a aucun champ : les joueurs n'écriront qu'un titre.");
}
return warnings;
}
private void RefreshPreview()
{
Preview.Clear();
if (_selectedForm is not null)
foreach (var field in _selectedForm.Build().Fields) Preview.Add(new FeedbackFieldViewModel(field));
foreach (var name in new[] { nameof(PreviewTitle), nameof(PreviewTitleHint), nameof(PreviewTechByDefault), nameof(PreviewNote) })
OnPropertyChanged(name);
}
private void Discard()
{
if (_state is null) return;
if (!Context.Dialogs.Confirm("Annuler les modifications ?",
"Les formulaires reviennent à ceux actuellement publiés. Les modifications non publiées seront perdues.", "Annuler les modifications"))
return;
StatusMessage = null;
LoadEditors(_state.Document.Forms);
}
/// <summary>Formulaires d'origine dans l'éditeur (à publier ensuite) : les quatre types, leurs champs d'origine.</summary>
private void RestoreOriginal()
{
if (!Context.Dialogs.Confirm("Revenir aux formulaires d'origine ?",
"Les types de demande redeviennent les quatre d'origine (avis, demande d'évolution, bug, autre), avec leurs noms, icônes " +
"et champs d'origine ; les types ajoutés disparaissent. " +
"Rien n'est envoyé avant « Publier » ; les demandes déjà reçues ne changent pas.", "Revenir à l'origine"))
return;
StatusMessage = null;
LoadEditors(FeedbackForms.All);
}
// ------------------------------------------------------------------ Types de demande (ajout, ordre, suppression, icône)
private FeedbackFormEditorActions EditorActions => new(MoveForm, RemoveForm, ImportIcon);
/// <summary>
/// Nouveau type de demande (identifiant généré, jamais celui d'un type publié ou d'origine), avec un champ « Message »
/// obligatoire ; placé à la fin et sélectionné.
/// </summary>
private void AddForm()
{
if (Forms.Count >= FeedbackFormRules.MaxForms) return;
var taken = Forms.Select(f => f.Kind).Concat(_publishedForms.Keys).Concat(FeedbackForms.All.Select(f => f.Kind))
.ToHashSet(StringComparer.Ordinal);
var n = 1;
while (taken.Contains($"type_{n}")) n++;
var form = new FeedbackForm($"type_{n}", "Nouveau type", "Décrivez votre demande.", "Forum", "", TechByDefault: false,
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) });
var editor = new FeedbackFormEditor(form, OnEdited, EditorActions, isNew: true);
Forms.Add(editor);
SelectedForm = editor;
}
private void MoveForm(FeedbackFormEditor form, int delta)
{
var index = Forms.IndexOf(form);
var target = index + delta;
if (index < 0 || target < 0 || target >= Forms.Count) return;
Forms.Move(index, target);
}
private void RemoveForm(FeedbackFormEditor form)
{
if (Forms.Count <= 1) return;
if (!Context.Dialogs.Confirm("Supprimer ce type de demande ?",
$"« {form.Title} » ne sera plus proposé aux joueurs après la publication. Les demandes déjà reçues de ce type restent " +
"consultables (avec leur formulaire d'alors). Pour le retirer seulement un temps, masquez-le plutôt.",
"Supprimer", danger: true))
return;
var index = Forms.IndexOf(form);
Forms.Remove(form);
if (ReferenceEquals(_selectedForm, form)) SelectedForm = Forms[Math.Min(index, Forms.Count - 1)];
}
/// <summary>
/// Icône importée : fichier .ico OBLIGATOIREMENT, contenant une image de 64 × 64 pixels, convertie en PNG neuf sans
/// métadonnées (<see cref="FeedbackIcons.FromIco"/>) ; tout autre fichier est refusé avec la raison.
/// </summary>
private void ImportIcon(FeedbackFormEditor form)
{
var path = Context.Dialogs.PickOpenFile($"Icône du type « {form.Title} » : fichier .ico contenant une image de {FeedbackIcons.Size} × {FeedbackIcons.Size} pixels",
"Icône Windows (*.ico)|*.ico", null);
if (path is null) return;
string? error;
byte[]? png = null;
try
{
if (!string.Equals(Path.GetExtension(path), ".ico", StringComparison.OrdinalIgnoreCase))
error = "Seuls les fichiers .ico sont acceptés.";
else if (new FileInfo(path).Length > FeedbackIcons.MaxIcoBytes)
error = $"Fichier trop volumineux (au plus {FeedbackIcons.MaxIcoBytes / 1024} Ko).";
else
error = FeedbackIcons.FromIco(File.ReadAllBytes(path), out png);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
error = $"Lecture du fichier impossible : {ex.Message}";
}
if (error is not null)
{
Context.Dialogs.ShowMessage("Icône refusée", error, ModuleMessageKind.Error);
return;
}
form.SetCustomIcon(Convert.ToBase64String(png!));
}
// ------------------------------------------------------------------ Publication
private async Task PublishAsync()
{
if (_state is null || _validationErrors.Count > 0) return;
var forms = Forms.Select(f => f.Build()).ToList();
var hidden = forms.Where(f => f.Hidden).Select(f => $"« {f.Label} »").ToList();
var message = "Les formulaires seront signés avec la clé de ce PC puis publiés : les launchers des joueurs les utiliseront " +
"à la prochaine ouverture de la fenêtre « Aide ». Les demandes déjà reçues gardent leur formulaire d'origine." +
(hidden.Count > 0 ? $"\n\nTypes masqués (plus proposés aux joueurs) : {string.Join(", ", hidden)}." : "");
if (!Context.Dialogs.Confirm("Publier les formulaires ?", message, "Publier")) return;
if (!await ConfirmPlayerSigningAsync(ContentKinds.FeedbackForms)) return;
await RunAsync(async () =>
{
StatusMessage = null;
try
{
var state = await Context.Session.PublishFeedbackFormsAsync(forms, _state.Document.Revision);
Apply(state);
StatusMessage = $"Formulaires publiés (révision {state.Document.Revision}) : les joueurs les verront à la prochaine " +
"ouverture de la fenêtre « Aide ».";
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.RevisionConflict)
{
HasConflict = true;
throw;
}
});
}
protected override void OnBusyChanged()
{
ReloadCommand.RaiseCanExecuteChanged();
PublishCommand.RaiseCanExecuteChanged();
DiscardCommand.RaiseCanExecuteChanged();
RestoreOriginalCommand.RaiseCanExecuteChanged();
AddFormCommand.RaiseCanExecuteChanged();
OnPropertyChanged(nameof(IsEditable));
}
private static string Snapshot(object value) => JsonSerializer.Serialize(value, ApiJson.Options);
}
@@ -71,8 +71,25 @@ public sealed class FeedbackItem : ObservableObject
public string Reference => _summary.Reference;
public string Title => _summary.Title;
public string Kind => _summary.Kind;
public string KindLabel => FeedbackForms.KindLabel(_summary.Kind);
public string KindIconKey => FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
/// <summary>Formulaire publié du type (nom, icône) ; null : formulaires d'origine.</summary>
public FeedbackForm? Form
{
get => _form;
set
{
_form = value;
foreach (var name in new[] { nameof(KindLabel), nameof(KindIconKey), nameof(KindCustomIcon), nameof(HasKindCustomIcon), nameof(DetailText) })
OnPropertyChanged(name);
}
}
private FeedbackForm? _form;
public string KindLabel => _form?.Label ?? FeedbackForms.KindLabel(_summary.Kind);
public string KindIconKey => _form?.IconKey ?? FeedbackForms.Find(_summary.Kind)?.IconKey ?? "Forum";
/// <summary>Icône importée du type (PNG 64 × 64) ; null : icône d'origine.</summary>
public byte[]? KindCustomIcon => _form?.CustomIconBytes();
public bool HasKindCustomIcon => KindCustomIcon is not null;
public string Status => _summary.Status;
public string StatusLabel => FeedbackStatuses.Label(_summary.Status);
public bool IsNew => _summary.Status == FeedbackStatuses.New;
@@ -242,8 +259,13 @@ public sealed class FeedbackDetailView : ObservableObject
public string Title => _detail.Summary.Title;
public string Reference => _detail.Summary.Reference;
public string KindLabel => FeedbackForms.KindLabel(_detail.Summary.Kind);
public string KindIconKey => FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
/// <summary>Nom du type tel qu'à l'envoi (formulaires publiés depuis le menu), sinon nom d'origine.</summary>
public string KindLabel => _detail.Form?.Label ?? FeedbackForms.KindLabel(_detail.Summary.Kind);
public string KindIconKey => _detail.Form?.IconKey ?? FeedbackForms.Find(_detail.Summary.Kind)?.IconKey ?? "Forum";
/// <summary>Icône importée du type tel qu'à l'envoi ; null : icône d'origine.</summary>
public byte[]? KindCustomIcon => _detail.Form?.CustomIconBytes();
public bool HasKindCustomIcon => KindCustomIcon is not null;
public string ReceivedText =>
$"Reçue le {_detail.Summary.ReceivedAt.ToLocalTime().ToString("dd/MM/yyyy 'à' HH:mm", CultureInfo.InvariantCulture)}";
@@ -299,14 +321,18 @@ public sealed class FeedbackDetailView : ObservableObject
/// <summary>Note telle qu'envoyée (vide : null).</summary>
public string? NoteToSend => string.IsNullOrWhiteSpace(_note) ? null : _note;
/// <summary>
/// Champs avec les libellés du formulaire TEL QU'À L'ENVOI (conservé avec la demande), ou ceux d'origine pour une
/// demande reçue avant la 3.2 : une demande reste lisible même si le formulaire a changé depuis.
/// </summary>
private static IReadOnlyList<FeedbackFieldLine> BuildLines(FeedbackDetail detail)
{
var form = FeedbackForms.Find(detail.Summary.Kind);
var form = detail.Form ?? FeedbackForms.Find(detail.Summary.Kind);
var lines = new List<FeedbackFieldLine>();
foreach (var field in form?.Fields ?? Array.Empty<FeedbackField>())
if (detail.Fields.TryGetValue(field.Id, out var value) && !string.IsNullOrEmpty(value))
lines.Add(new FeedbackFieldLine(field.Label, field.DisplayValue(value), field.Kind == FeedbackFieldKind.Multiline,
field.Kind == FeedbackFieldKind.Multiline && FeedbackLinks.Contains(value)));
field.IsText && FeedbackLinks.Contains(value)));
// Champ d'une version plus récente du formulaire, inconnu de ce launcher : affiché tel quel.
foreach (var (id, value) in detail.Fields.Where(f => form?.Field(f.Key) is null))
lines.Add(new FeedbackFieldLine(id, value, value.Length > 60 || value.Contains('\n'), FeedbackLinks.Contains(value)));
@@ -46,8 +46,8 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
context.Games.First(g => g.GameId == c.Value).GameName, choice => SelectedGame = choice), StringComparer.OrdinalIgnoreCase);
GameTiles = _tiles.Values.ToList();
Kinds = new[] { new Choice(null, "Tous les types") }
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))).ToList();
Kinds = new ObservableCollection<Choice>(new[] { new Choice(null, "Tous les types") }
.Concat(FeedbackForms.All.Select(f => new Choice(f.Kind, f.Label))));
Statuses = new[]
{
new Choice("open", "À traiter (nouvelles et en cours)"),
@@ -121,7 +121,42 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
// ------------------------------------------------------------------ Filtres et liste
public IReadOnlyList<Choice> Kinds { get; }
/// <summary>Types de demande du filtre : ceux publiés depuis le menu (même masqués), puis ceux d'origine absents.</summary>
public ObservableCollection<Choice> Kinds { get; }
/// <summary>Formulaires publiés (noms et icônes des types dans la liste) ; vide : formulaires d'origine.</summary>
private Dictionary<string, FeedbackForm> _publishedForms = new(StringComparer.Ordinal);
private FeedbackForm? FormOf(string kind) =>
_publishedForms.TryGetValue(kind, out var form) ? form : FeedbackForms.Find(kind);
/// <summary>
/// Noms et icônes des types publiés depuis la page « Formulaires des demandes » (lecture permise à tout
/// administrateur : contenu public). En cas d'échec, les noms d'origine restent affichés.
/// </summary>
private async Task LoadKindsAsync()
{
try
{
var state = await Context.Session.GetFeedbackFormsAsync();
if (state.Document.Revision == 0) return;
_publishedForms = state.Document.Forms.ToDictionary(f => f.Kind, StringComparer.Ordinal);
}
catch (ApiException)
{
return;
}
var selected = _kind.Value;
var choices = new[] { new Choice(null, "Tous les types") }
.Concat(_publishedForms.Values.Select(f => new Choice(f.Kind, f.Hidden ? $"{f.Label} (masqué)" : f.Label)))
.Concat(FeedbackForms.All.Where(f => !_publishedForms.ContainsKey(f.Kind)).Select(f => new Choice(f.Kind, $"{f.Label} (supprimé)")))
.ToList();
Kinds.Clear();
foreach (var choice in choices) Kinds.Add(choice);
_kind = Kinds.FirstOrDefault(k => k.Value == selected) ?? Kinds[0];
OnPropertyChanged(nameof(Kind));
foreach (var item in _loaded) item.Form = FormOf(item.Kind);
}
public IReadOnlyList<Choice> Statuses { get; }
public Choice Kind
@@ -262,7 +297,12 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
DeleteCommand.RaiseCanExecuteChanged();
}
protected override Task OnActivatedAsync(bool firstTime) => firstTime ? LoadAsync() : Task.CompletedTask;
protected override async Task OnActivatedAsync(bool firstTime)
{
if (!firstTime) return;
await LoadKindsAsync();
await LoadAsync();
}
// ------------------------------------------------------------------ Chargement
@@ -354,7 +394,7 @@ public sealed class FeedbackPanelViewModel : AdminPanelViewModel
private void AddPage(FeedbackPage page)
{
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s)));
_loaded.AddRange(page.Items.Select(s => new FeedbackItem(s) { Form = FormOf(s.Kind) }));
_nextCursor = page.NextCursor;
_total = page.Total;
ApplySearch();
@@ -14,6 +14,8 @@
<ItemGroup>
<ProjectReference Include="..\GameLauncher.Modules\GameLauncher.Modules.csproj" />
<!-- Aperçu des formulaires des demandes : mêmes champs que la fenêtre « Aide » des joueurs -->
<ProjectReference Include="..\GameLauncher.Modules.Feedback\GameLauncher.Modules.Feedback.csproj" />
</ItemGroup>
<ItemGroup>
@@ -3,20 +3,23 @@ using GameLauncher.Mvvm;
namespace GameLauncher.Modules.Feedback;
/// <summary>Étoile d'un champ « note » (1 à 5).</summary>
/// <summary>Étoile d'un champ « note » (1 à 3, 5 ou 10 étoiles).</summary>
public sealed class RatingStar : ObservableObject
{
private bool _isOn;
public RatingStar(int value, Action<int> select)
public RatingStar(int value, Action<int> select, int max = 5)
{
Value = value;
Max = max;
SelectCommand = new RelayCommand(() => select(value));
}
public int Value { get; }
public int Max { get; }
public string ToolTip => Value switch
/// <summary>Appréciation (sur 5 étoiles), sinon « 7 / 10 ».</summary>
public string ToolTip => Max != 5 ? $"{Value} / {Max}" : Value switch
{
1 => "1 étoile : décevant",
2 => "2 étoiles : moyen",
@@ -34,7 +37,35 @@ public sealed class RatingStar : ObservableObject
public RelayCommand SelectCommand { get; }
}
/// <summary>Champ du formulaire, généré à partir de sa description partagée (<see cref="FeedbackField"/>).</summary>
/// <summary>Case d'un champ « cases à cocher ».</summary>
public sealed class FeedbackCheckOption : ObservableObject
{
private readonly Action _changed;
private bool _isChecked;
public FeedbackCheckOption(FeedbackOption option, Action changed)
{
Option = option;
_changed = changed;
}
public FeedbackOption Option { get; }
public string Label => Option.Label;
public bool IsChecked
{
get => _isChecked;
set
{
if (SetProperty(ref _isChecked, value)) _changed();
}
}
}
/// <summary>
/// Champ du formulaire, généré à partir de sa description (<see cref="FeedbackField"/>, formulaires publiés ou
/// d'origine) : zone de texte, texte court, liste déroulante, cases à cocher, case oui/non ou note en étoiles.
/// </summary>
public sealed class FeedbackFieldViewModel : ObservableObject
{
/// <summary>Choix « non précisé » des listes facultatives.</summary>
@@ -43,6 +74,7 @@ public sealed class FeedbackFieldViewModel : ObservableObject
private string _text = "";
private FeedbackOption? _selectedOption;
private int _rating;
private bool _isChecked;
private string? _error;
public FeedbackFieldViewModel(FeedbackField field)
@@ -50,17 +82,33 @@ public sealed class FeedbackFieldViewModel : ObservableObject
Field = field;
Options = field.Required ? field.Choices : new[] { NoChoice }.Concat(field.Choices).ToList();
if (!field.Required) _selectedOption = NoChoice;
Stars = Enumerable.Range(1, 5).Select(i => new RatingStar(i, value => Rating = value)).ToList();
var max = field.Kind == FeedbackFieldKind.Rating ? Math.Clamp(field.RatingMax, 1, 10) : 5;
Stars = Enumerable.Range(1, max).Select(i => new RatingStar(i, value => Rating = value, max)).ToList();
CheckOptions = field.Kind == FeedbackFieldKind.Checkboxes
? field.Choices.Select(o => new FeedbackCheckOption(o, () => Error = null)).ToList()
: Array.Empty<FeedbackCheckOption>();
}
public FeedbackField Field { get; }
public string Id => Field.Id;
/// <summary>Libellé (« * » : obligatoire ; une case oui/non porte son libellé à côté de la case).</summary>
public string Label => Field.Required ? Field.Label + " *" : Field.Label;
public string? Hint => Field.Hint;
public bool IsMultiline => Field.Kind == FeedbackFieldKind.Multiline;
public bool IsText => Field.Kind == FeedbackFieldKind.Text;
public bool IsChoice => Field.Kind == FeedbackFieldKind.Choice;
public bool IsRating => Field.Kind == FeedbackFieldKind.Rating;
public bool IsCheckboxes => Field.Kind == FeedbackFieldKind.Checkboxes;
public bool IsYesNo => Field.Kind == FeedbackFieldKind.YesNo;
/// <summary>Libellé au-dessus du champ (tous sauf la case oui/non, dont le libellé est à côté de la case).</summary>
public bool ShowLabel => !IsYesNo;
/// <summary>Aide affichée sous le champ (champs sans zone de saisie ; sinon elle sert de texte indicatif).</summary>
public bool ShowHintText => !Field.IsText && !string.IsNullOrEmpty(Field.Hint);
public int MaxLength => Field.MaxLength;
public string Text
@@ -91,14 +139,55 @@ public sealed class FeedbackFieldViewModel : ObservableObject
}
}
/// <summary>
/// Reprend la saisie d'un champ du même identifiant et du même type (formulaires republiés pendant la saisie) :
/// seules les valeurs encore valables sont reprises (choix toujours proposés, note dans la nouvelle échelle).
/// </summary>
public void RestoreFrom(FeedbackFieldViewModel previous)
{
if (previous.Field.Kind != Field.Kind) return;
switch (Field.Kind)
{
case FeedbackFieldKind.Choice:
if (Options.FirstOrDefault(o => o.Id == previous.SelectedOption?.Id) is { } option) SelectedOption = option;
break;
case FeedbackFieldKind.Checkboxes:
var chosen = previous.CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id).ToHashSet(StringComparer.Ordinal);
foreach (var box in CheckOptions) box.IsChecked = chosen.Contains(box.Option.Id);
break;
case FeedbackFieldKind.Rating:
if (previous.Rating <= Stars.Count) Rating = previous.Rating;
break;
case FeedbackFieldKind.YesNo:
IsChecked = previous.IsChecked;
break;
default:
Text = previous.Text;
break;
}
}
public IReadOnlyList<RatingStar> Stars { get; }
/// <summary>Cases d'un champ « cases à cocher ».</summary>
public IReadOnlyList<FeedbackCheckOption> CheckOptions { get; }
/// <summary>Case oui/non cochée.</summary>
public bool IsChecked
{
get => _isChecked;
set
{
if (SetProperty(ref _isChecked, value)) Error = null;
}
}
public int Rating
{
get => _rating;
set
{
if (!SetProperty(ref _rating, Math.Clamp(value, 0, 5))) return;
if (!SetProperty(ref _rating, Math.Clamp(value, 0, Stars.Count))) return;
foreach (var star in Stars) star.IsOn = star.Value <= _rating;
OnPropertyChanged(nameof(RatingText));
Error = null;
@@ -124,6 +213,10 @@ public sealed class FeedbackFieldViewModel : ObservableObject
{
FeedbackFieldKind.Choice => string.IsNullOrEmpty(_selectedOption?.Id) ? null : _selectedOption!.Id,
FeedbackFieldKind.Rating => _rating == 0 ? null : _rating.ToString(System.Globalization.CultureInfo.InvariantCulture),
FeedbackFieldKind.Checkboxes => CheckOptions.Any(o => o.IsChecked)
? string.Join(',', CheckOptions.Where(o => o.IsChecked).Select(o => o.Option.Id))
: null,
FeedbackFieldKind.YesNo => _isChecked ? FeedbackRules.Yes : FeedbackRules.No,
_ => string.IsNullOrWhiteSpace(_text) ? null : _text
};
@@ -40,6 +40,7 @@ public sealed class FeedbackKindChoice
public FeedbackKindChoice(FeedbackForm form, Action<FeedbackForm> select)
{
Form = form;
CustomIcon = form.CustomIconBytes();
SelectCommand = new RelayCommand(() => select(form));
}
@@ -47,6 +48,10 @@ public sealed class FeedbackKindChoice
public string Label => Form.Label;
public string Description => Form.Description;
public string IconKey => Form.IconKey;
/// <summary>Icône importée depuis le menu (PNG 64 × 64, vérifiée avec les formulaires signés) ; null : icône d'origine.</summary>
public byte[]? CustomIcon { get; } = null;
public bool HasCustomIcon => CustomIcon is not null;
public RelayCommand SelectCommand { get; }
}
@@ -59,8 +64,11 @@ public enum FeedbackStep
}
/// <summary>
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires partagés), envoi, référence.
/// Fenêtre « Aide » : choix du type de demande, formulaire (généré à partir des formulaires publiés depuis le menu, ou
/// de ceux d'origine), envoi, référence.
/// • le jeu est celui sélectionné à l'ouverture, affiché et non modifiable ;
/// • formulaires lus à l'ouverture (<see cref="IFeedbackFormsSource"/> : publiés et vérifiés, sinon d'origine) ; s'ils
/// sont republiés pendant la saisie (409), ils sont relus et la saisie des champs restés identiques est reprise ;
/// • contrôle avant l'envoi avec les règles du serveur (<see cref="FeedbackRules"/>) ;
/// • en cas d'échec (réseau, serveur occupé…), la saisie est conservée et l'envoi peut être relancé ;
/// • demande ANONYME : aucun identifiant du joueur ni du PC ; informations techniques facultatives, montrées avant l'envoi.
@@ -69,6 +77,9 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
private readonly IFeedbackSender _sender;
private readonly IScreenshotSource? _screenshots;
private readonly IFeedbackFormsSource? _formsSource;
private FeedbackFormsDocument _forms = FeedbackFormRules.Defaults();
private bool _isLoadingForms;
private string? _attachmentError;
private string? _attachmentWarning;
private readonly FeedbackTechInfo _tech;
@@ -82,12 +93,14 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
private string? _errorMessage;
private string? _reference;
/// <param name="forms">Formulaires publiés (null : formulaires d'origine, affichés tout de suite).</param>
public FeedbackFormViewModel(FeedbackTarget target, IFeedbackSender sender, FeedbackTechInfo? tech = null,
IScreenshotSource? screenshots = null)
IScreenshotSource? screenshots = null, IFeedbackFormsSource? forms = null)
{
Target = target;
_sender = sender;
_screenshots = screenshots;
_formsSource = forms;
AddFileCommand = new RelayCommand(() => AddScreenshot(_screenshots?.PickFile()), () => CanAddScreenshot);
PasteCommand = new RelayCommand(() => AddScreenshot(_screenshots?.FromClipboard(), fromClipboard: true), () => CanAddScreenshot);
Attachments.CollectionChanged += (_, _) =>
@@ -99,10 +112,79 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
PasteCommand.RaiseCanExecuteChanged();
};
_tech = tech ?? FeedbackTech.Collect(target.LauncherVersion, target.InstalledVersion);
Kinds = FeedbackForms.All.Select(f => new FeedbackKindChoice(f, Choose)).ToList();
SendCommand = new AsyncRelayCommand(SendAsync, () => _form is not null && !_isSending && _step == FeedbackStep.Fill);
BackCommand = new RelayCommand(Back, () => !_isSending && _step == FeedbackStep.Fill);
CloseCommand = new RelayCommand(() => CloseRequested?.Invoke());
ApplyForms(_forms);
FormsLoad = forms is null ? Task.CompletedTask : LoadFormsAsync(refresh: false);
}
/// <summary>Lecture des formulaires lancée à l'ouverture (tests, captures d'écran de l'interface).</summary>
public Task FormsLoad { get; private set; }
/// <summary>Révision des formulaires affichés (0 : formulaires d'origine), envoyée avec la demande.</summary>
public long FormsRevision => _forms.Revision;
/// <summary>Formulaires en cours de lecture (les types de demande apparaissent ensuite).</summary>
public bool IsLoadingForms
{
get => _isLoadingForms;
private set => SetProperty(ref _isLoadingForms, value);
}
private async Task LoadFormsAsync(bool refresh)
{
IsLoadingForms = true;
try
{
ApplyForms(await _formsSource!.LoadAsync(refresh, CancellationToken.None));
}
catch (Exception ex)
{
Log.Warn($"Formulaires des demandes : {ex.Message} (formulaires d'origine utilisés).");
ApplyForms(FeedbackFormRules.Defaults());
}
finally
{
IsLoadingForms = false;
}
}
/// <summary>Types proposés : ceux non masqués des formulaires donnés.</summary>
private void ApplyForms(FeedbackFormsDocument forms)
{
_forms = forms;
Kinds.Clear();
foreach (var form in forms.Visible) Kinds.Add(new FeedbackKindChoice(form, Choose));
OnPropertyChanged(nameof(FormsRevision));
}
/// <summary>
/// Formulaires republiés pendant la saisie : le type en cours est reconstruit avec sa nouvelle définition, et la
/// saisie des champs de même identifiant et de même type est reprise. Type masqué depuis : retour au choix du type.
/// </summary>
private void RebuildCurrentForm()
{
if (_form is null) return;
var updated = _forms.Visible.FirstOrDefault(f => f.Kind == _form.Kind);
if (updated is null)
{
Form = null;
Fields.Clear();
Step = FeedbackStep.ChooseKind;
ErrorMessage = "Ce type de demande n'est plus proposé : choisissez-en un autre.";
return;
}
var previous = Fields.ToDictionary(f => f.Id, StringComparer.Ordinal);
Form = updated;
Fields.Clear();
foreach (var field in updated.Fields)
{
var view = new FeedbackFieldViewModel(field);
if (previous.TryGetValue(field.Id, out var old)) view.RestoreFrom(old);
Fields.Add(view);
}
ErrorMessage = "Le formulaire vient d'être mis à jour : vérifiez votre saisie, puis cliquez de nouveau sur « Envoyer ».";
}
public FeedbackTarget Target { get; }
@@ -110,7 +192,7 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
/// <summary>« Demande pour : Exovival Zombie Reborn ».</summary>
public string GameText => $"Demande pour : {Target.GameName}";
public IReadOnlyList<FeedbackKindChoice> Kinds { get; }
public ObservableCollection<FeedbackKindChoice> Kinds { get; } = new();
/// <summary>La fenêtre doit se fermer (« Fermer », « Annuler »).</summary>
public event Action? CloseRequested;
@@ -142,12 +224,22 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
if (!SetProperty(ref _form, value)) return;
OnPropertyChanged(nameof(FormTitle));
OnPropertyChanged(nameof(FormIconKey));
OnPropertyChanged(nameof(FormCustomIcon));
OnPropertyChanged(nameof(HasFormCustomIcon));
OnPropertyChanged(nameof(TitleHint));
OnPropertyChanged(nameof(AttachmentsAllowed));
OnPropertyChanged(nameof(CanAddScreenshot));
AddFileCommand.RaiseCanExecuteChanged();
PasteCommand.RaiseCanExecuteChanged();
}
}
public string FormTitle => _form?.Label ?? "Aide";
public string FormIconKey => _form?.IconKey ?? "MessageQuestion";
/// <summary>Icône importée du type choisi (null : icône d'origine <see cref="FormIconKey"/>).</summary>
public byte[]? FormCustomIcon => _form?.CustomIconBytes();
public bool HasFormCustomIcon => FormCustomIcon is not null;
public string TitleHint => _form?.TitleHint ?? "";
private void Choose(FeedbackForm form)
@@ -228,8 +320,11 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
/// <summary>Captures possibles (source disponible : fenêtre WPF).</summary>
public bool ScreenshotsAvailable => _screenshots is not null;
/// <summary>Captures proposées : source disponible ET autorisées pour ce type de demande (réglage du menu).</summary>
public bool AttachmentsAllowed => _screenshots is not null && (_form?.AllowAttachments ?? true);
public bool HasAttachments => Attachments.Count > 0;
public bool CanAddScreenshot => _screenshots is not null && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
public bool CanAddScreenshot => AttachmentsAllowed && !_isSending && Attachments.Count < FeedbackImages.MaxAttachments;
public string AttachmentsText =>
$"{Attachments.Count} / {FeedbackImages.MaxAttachments} · PNG, JPEG, BMP, GIF ou WebP · {FeedbackImages.MaxBytes / 1024 / 1024} Mo au plus après conversion";
@@ -365,12 +460,13 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
GameId = Target.GameId,
Kind = _form.Kind,
FormVersion = FeedbackForms.CurrentVersion,
FormRevision = _forms.Revision,
Title = _title,
Fields = Fields.Where(f => f.Value is not null).ToDictionary(f => f.Id, f => f.Value!),
Tech = _includeTech ? _tech : null
};
var errors = new List<string>();
var clean = FeedbackRules.Validate(submission, errors);
var clean = FeedbackRules.Validate(submission, _form, errors);
if (clean is null) ErrorMessage = errors.FirstOrDefault();
return clean;
}
@@ -390,10 +486,12 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
_sending = new CancellationTokenSource();
try
{
var sent = await _sender.SendAsync(submission, Attachments.Select(a => a.Content).ToList(), _sending.Token);
// Captures seulement si ce type de demande les accepte (réglage du menu ; le serveur le revérifie).
IReadOnlyList<byte[]> attachments = AttachmentsAllowed ? Attachments.Select(a => a.Content).ToList() : Array.Empty<byte[]>();
var sent = await _sender.SendAsync(submission, attachments, _sending.Token);
Reference = sent.Reference;
AttachmentWarning = sent.AttachmentsSent < Attachments.Count
? $"{Attachments.Count - sent.AttachmentsSent} capture(s) sur {Attachments.Count} n'ont pas pu être envoyées" +
AttachmentWarning = sent.AttachmentsSent < attachments.Count
? $"{attachments.Count - sent.AttachmentsSent} capture(s) sur {attachments.Count} n'ont pas pu être envoyées" +
(sent.AttachmentError is null ? "." : $" ({sent.AttachmentError}).") + " La demande, elle, est bien enregistrée."
: null;
Log.Info($"[{Target.GameId}] Demande envoyée ({_form.Kind}) : {Reference}, {sent.AttachmentsSent} capture(s)");
@@ -403,6 +501,12 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
{
// fenêtre fermée pendant l'envoi
}
catch (ApiException ex) when (ex.Code == ApiErrorCodes.FeedbackFormChanged && _formsSource is not null)
{
Log.Info($"[{Target.GameId}] Formulaires des demandes republiés pendant la saisie : relus.");
await LoadFormsAsync(refresh: true);
RebuildCurrentForm();
}
catch (Exception ex)
{
Log.Warn($"[{Target.GameId}] Envoi d'une demande impossible : {ex.Message}");
@@ -430,6 +534,8 @@ public sealed class FeedbackFormViewModel : ObservableObject, IDisposable
ApiException { Code: ApiErrorCodes.FeedbackFull } or ApiException { Status: >= 500 } =>
"Le service des demandes est momentanément indisponible. Réessayez plus tard : votre saisie est conservée.",
ApiException { Code: ApiErrorCodes.ProofInvalid } => "Vérification anti-spam échouée : cliquez de nouveau sur « Envoyer ».",
ApiException { Code: ApiErrorCodes.FeedbackFormChanged } =>
"Le formulaire vient d'être mis à jour : fermez puis rouvrez la fenêtre « Aide » (votre saisie est à refaire).",
ApiException { Status: 400 } api => api.Message,
_ => "L'envoi a échoué. Réessayez dans un instant : votre saisie est conservée."
};
@@ -0,0 +1,157 @@
using System.Net;
using System.Text.Json;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Core.Trust;
using GameLauncher.Core.Utils;
namespace GameLauncher.Modules.Feedback;
/// <summary>Formulaires à proposer au joueur (publiés et vérifiés, sinon ceux d'origine).</summary>
public interface IFeedbackFormsSource
{
/// <param name="refresh">Relire le fichier publié (formulaires republiés pendant la saisie : réponse 409 du serveur).</param>
Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct);
}
/// <summary>
/// Formulaires des demandes publiés depuis le menu d'administration (feedback-forms.json, à côté du trousseau), lus à
/// l'ouverture de la fenêtre « Aide » seulement :
/// • fichier borné en taille, contrôlé avec les règles des formulaires (même signé, un fichier hors limites est refusé) ;
/// • signature vérifiée avec le trousseau (autorisation « forms »), retour en arrière refusé (révision et date de
/// signature), selon le mode « contentSigning » du launcher, comme le catalogue ;
/// • dernière copie acceptée gardée dans les données du launcher (feedback\forms.json), revérifiée à la lecture ;
/// • rien de valide (serveur injoignable, rien de publié, signature refusée) : formulaires d'origine (révision 0),
/// toujours acceptés par le serveur.
/// </summary>
public sealed class FeedbackFormsService : IFeedbackFormsSource
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(8);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
private readonly Uri? _url;
private readonly ContentTrustService? _trust;
private readonly SemaphoreSlim _gate = new(1, 1);
private FeedbackFormsDocument? _loaded;
/// <param name="url">Adresse du fichier publié (null : formulaires d'origine seulement).</param>
/// <param name="trust">Vérification des signatures (null : mode « off », formulaires utilisés sans signature).</param>
public FeedbackFormsService(Func<Uri, long, CancellationToken, Task<byte[]>> download, Uri? url, string dataDirectory,
ContentTrustService? trust)
{
_download = download;
_url = url;
_trust = trust;
CachePath = Path.Combine(dataDirectory, "feedback", "forms.json");
}
public string CachePath { get; }
/// <summary>Adresse du fichier publié, déduite de celle du trousseau (ou, à défaut, du catalogue) : même dossier.</summary>
public static Uri? PublishedUrl(string? keyringUrl, string? catalogUrl)
{
var baseUrl = keyringUrl ?? catalogUrl;
return baseUrl is not null && Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri) ? new Uri(uri, "feedback-forms.json") : null;
}
public async Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
try
{
if (_loaded is not null && !refresh) return _loaded;
var cached = ReadCache();
var fallback = cached ?? FeedbackFormRules.Defaults();
if (_url is null) return _loaded = fallback;
byte[] bytes;
try
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeout.CancelAfter(FetchTimeout);
bytes = await _download(_url, FeedbackFormRules.MaxBytes, timeout.Token).ConfigureAwait(false);
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
Log.Info("Formulaires des demandes : aucun formulaire publié, formulaires d'origine utilisés.");
return _loaded = fallback;
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
// Réseau, délai dépassé, réponse trop grande : pas mémorisé, nouvel essai à la prochaine ouverture.
Log.Warn($"Formulaires des demandes injoignables : {ex.Message}" +
(cached is null ? " (formulaires d'origine utilisés)." : $" (révision {cached.Revision} en cache utilisée)."));
return fallback;
}
var published = await AcceptAsync(bytes, ct).ConfigureAwait(false);
if (published is null) return _loaded = fallback;
if (cached is not null && published.Revision < cached.Revision)
{
Log.Error($"Formulaires des demandes révision {published.Revision} refusés : plus anciens que la révision {cached.Revision} " +
"déjà vue (retour en arrière).");
return _loaded = cached;
}
WriteCache(bytes);
Log.Info($"Formulaires des demandes : révision {published.Revision}.");
return _loaded = published;
}
finally
{
_gate.Release();
}
}
/// <summary>Fichier publié lisible, dans les règles et à la signature acceptée ; sinon null (raison au journal).</summary>
private async Task<FeedbackFormsDocument?> AcceptAsync(byte[] bytes, CancellationToken ct)
{
FeedbackFormsDocument? document;
try
{
document = FeedbackFormRules.Parse(bytes);
}
catch (JsonException ex)
{
Log.Error($"Formulaires des demandes illisibles ({ex.Message}) : ignorés.");
return null;
}
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors))
{
Log.Error($"Formulaires des demandes refusés : {errors[0]}");
return null;
}
if (_trust is not null && !await _trust.VerifyFeedbackFormsAsync(document!, ct).ConfigureAwait(false)) return null;
return document;
}
/// <summary>Dernière copie acceptée, revérifiée (règles et signature avec le trousseau en cache), sinon null.</summary>
private FeedbackFormsDocument? ReadCache()
{
try
{
if (!File.Exists(CachePath) || new FileInfo(CachePath).Length > FeedbackFormRules.MaxBytes) return null;
var document = FeedbackFormRules.Parse(File.ReadAllBytes(CachePath));
var errors = new List<string>();
if (!FeedbackFormRules.Validate(document, errors)) return null;
return _trust is null || _trust.VerifyCachedFeedbackForms(document!) ? document : null;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Formulaires des demandes en cache illisibles : {ex.Message}");
return null;
}
}
private void WriteCache(byte[] bytes)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(CachePath)!);
FileUtil.WriteAllTextAtomic(CachePath, System.Text.Encoding.UTF8.GetString(bytes));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Formulaires des demandes non gardés en cache : {ex.Message}");
}
}
}
@@ -42,7 +42,7 @@ public sealed class PlayerSigningCheck
/// Texte de l'avertissement à afficher avant de publier un contenu de ce type signé par cette clé, ou null si les
/// launchers des joueurs l'accepteront (ou ne vérifient pas les signatures). Ne lève pas d'exception (sauf annulation).
/// </summary>
/// <param name="kind"><see cref="ContentKinds.Catalog"/> ou <see cref="ContentKinds.Manifest"/>.</param>
/// <param name="kind"><see cref="ContentKinds.Catalog"/>, <see cref="ContentKinds.Manifest"/> ou <see cref="ContentKinds.FeedbackForms"/>.</param>
/// <param name="publicKey">Clé publique (SPKI) de ce PC : celle qui signera, lue localement.</param>
public async Task<string?> WarningAsync(string kind, byte[] publicKey, CancellationToken ct = default)
{
@@ -108,7 +108,10 @@ public sealed class PlayerSigningCheck
{
var catalog = kind == ContentKinds.Catalog;
var consequence = Mode == ContentSigningMode.Require
? catalog
? kind == ContentKinds.FeedbackForms
? "Les launchers des joueurs (signatures obligatoires) ignoreront ces formulaires : la fenêtre « Aide » proposera " +
"les derniers formulaires acceptés, ou ceux d'origine."
: catalog
? "Les launchers des joueurs (signatures obligatoires) ignoreront ce catalogue : ils garderont le précédent " +
"(celui intégré au launcher pour une installation neuve)."
: "Les launchers des joueurs (signatures obligatoires) refuseront cette version : ni installation, ni mise à jour " +
+2
View File
@@ -11,6 +11,8 @@
<ResourceDictionary Source="Themes/Controls.xaml" />
<!-- Notes de version (format enrichi) : modèles partagés par la fenêtre principale et la fenêtre agrandie -->
<ResourceDictionary Source="Themes/Changelog.xaml" />
<!-- Champs des formulaires des demandes : fenêtre « Aide » et aperçu de la page du menu -->
<ResourceDictionary Source="Themes/FeedbackForms.xaml" />
<!-- Menu d'administration : styles et modèles des pages des modules (Views/Admin) -->
<ResourceDictionary Source="Themes/Admin.xaml" />
</ResourceDictionary.MergedDictionaries>
+9 -1
View File
@@ -59,6 +59,9 @@ public partial class App : Application, ILauncherRestartHost
private AdminWindowService? _admin;
private CatalogService? _catalogService;
private ContentTrustService? _trust;
/// <summary>Adresse du trousseau (même dossier que les formulaires publiés des demandes).</summary>
private string? _keyringUrl;
private PlayerSigningCheck? _playerSigning;
private Task<CatalogDocument?>? _catalogFetch;
private string _launcherPath = "";
@@ -365,7 +368,11 @@ public partial class App : Application, ILauncherRestartHost
window.AdminRequested += _admin.Open;
// Bouton « Aide » (demandes des joueurs) : même adresse d'API, lue au même endroit (launcher.json intégré en Release).
var feedback = new FeedbackWindowService(window, apiUrl, version);
// Formulaires publiés depuis le menu (feedback-forms.json, à côté du trousseau), vérifiés comme le catalogue.
var formsUrl = GameLauncher.Modules.Feedback.FeedbackFormsService.PublishedUrl(_keyringUrl, ResolveCatalogUrl(catalog));
var forms = _http is null ? null
: new GameLauncher.Modules.Feedback.FeedbackFormsService(_http.GetBytesAsync, formsUrl, catalog.DataDirectory, _trust);
var feedback = new FeedbackWindowService(window, apiUrl, version, forms);
launcher.IsHelpAvailable = feedback.IsAvailable;
window.HelpRequested += game => feedback.Open(game.GameId, game.GameName, game.InstalledVersion);
}
@@ -480,6 +487,7 @@ public partial class App : Application, ILauncherRestartHost
private ContentTrustService? CreateContentTrust(LauncherCatalog catalog)
{
var (keyringUrl, mode, minSequence) = ResolveContentSigning(catalog);
_keyringUrl = keyringUrl;
if (mode == ContentSigningMode.Off) return null;
var roots = LauncherUpdateService.LoadRootKeys();
if (roots.Count == 0)
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.1.0</Version>
<Version>3.2.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -10,19 +10,23 @@ namespace GameLauncher.Services;
/// Bouton « Aide » de la fenêtre principale : ouvre le formulaire de demande pour le jeu sélectionné (fenêtre modale,
/// le jeu ne peut donc pas changer pendant la saisie). L'adresse de l'API est celle du menu d'administration
/// (« apiUrl » du launcher.json INTÉGRÉ en Release) : sans elle, le bouton n'est pas affiché.
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan.
/// Le launcher ne contacte l'API qu'au moment de l'envoi, jamais en arrière-plan ; les formulaires publiés (fichier
/// statique signé, à côté du trousseau) ne sont lus qu'à l'ouverture de la fenêtre.
/// </summary>
public sealed class FeedbackWindowService
{
private readonly MainWindow _owner;
private readonly Uri? _apiUrl;
private readonly string _launcherVersion;
private readonly IFeedbackFormsSource? _forms;
private LauncherApiClient? _client;
private FeedbackWindow? _window;
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion)
/// <param name="forms">Formulaires publiés (null : formulaires d'origine).</param>
public FeedbackWindowService(MainWindow owner, string? apiUrl, string launcherVersion, IFeedbackFormsSource? forms = null)
{
_owner = owner;
_forms = forms;
_apiUrl = apiUrl is not null && Uri.TryCreate(apiUrl, UriKind.Absolute, out var uri) ? uri : null;
_launcherVersion = launcherVersion;
}
@@ -44,7 +48,7 @@ public sealed class FeedbackWindowService
// Agent utilisateur générique (version du launcher seulement) : rien qui identifie le joueur ou son PC.
_client ??= new LauncherApiClient(_apiUrl, $"GameLauncher/{_launcherVersion}");
var viewModel = new FeedbackFormViewModel(new FeedbackTarget(gameId, gameName, installedVersion, _launcherVersion),
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window));
new ApiFeedbackSender(_client), screenshots: new ScreenshotSource(() => _window), forms: _forms);
var window = new FeedbackWindow(viewModel);
if (_owner.IsVisible) window.Owner = _owner;
else window.WindowStartupLocation = WindowStartupLocation.CenterScreen;
+443 -3
View File
@@ -2123,8 +2123,14 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="30" Height="30" CornerRadius="9" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Path x:Name="KindIcon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
<Grid>
<Path x:Name="KindIcon" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}"
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Image Width="20" Height="20" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding KindCustomIcon, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="10,0,8,0">
<TextBlock x:Name="ItemTitle" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
@@ -2196,7 +2202,13 @@
<StackPanel>
<DockPanel>
<Border DockPanel.Dock="Left" Style="{StaticResource AdminIconBadgeStyle}" Margin="0,0,14,0">
<Path Style="{StaticResource AdminIconStyle}" Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
<Grid>
<Path Style="{StaticResource AdminIconStyle}" Data="{Binding KindIconKey, Converter={StaticResource IconKey}}"
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Image Width="24" Height="24" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding KindCustomIcon, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasKindCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<Button DockPanel.Dock="Right" Style="{StaticResource IconButtonStyle}" Width="32" Height="32" Padding="8" VerticalAlignment="Top"
ToolTip="Copier la référence" ui:IconAssist.Icon="{StaticResource Icon.ContentCopy}"
@@ -2429,6 +2441,434 @@
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Formulaires des demandes » (feedback.forms) -->
<!-- Type de demande (liste de gauche) -->
<Style x:Key="AdminFeedbackFormItemStyle" TargetType="ListBoxItem">
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd" CornerRadius="12" Padding="12,10" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Width="32" Height="32" CornerRadius="10" VerticalAlignment="Top" Background="{DynamicResource BadgeBrush}">
<Grid>
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Image Width="22" Height="22" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding CustomIconBytes, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock x:Name="ItemTitle" Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis" Foreground="{DynamicResource TextMutedBrush}">
<Run Text="{Binding OriginalLabel, Mode=OneWay}" /><Run Text=" · " /><Run Text="{Binding FieldsText, Mode=OneWay}" />
</TextBlock>
<TextBlock x:Name="State" Text="{Binding StateText}" FontSize="11" FontWeight="SemiBold" Margin="0,3,0,0"
Foreground="{DynamicResource Accent2Brush}" />
</StackPanel>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource BorderStrongBrush}" />
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="BorderBrush" Value="{DynamicResource Accent2Brush}" />
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceAltBrush}" />
</Trigger>
<DataTrigger Binding="{Binding Hidden}" Value="True">
<Setter TargetName="State" Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
<Setter TargetName="ItemTitle" Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</DataTrigger>
<DataTrigger Binding="{Binding IsDirty}" Value="True">
<Setter TargetName="State" Property="Foreground" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Ligne cliquable sans cadre (déplier / replier les réglages d'un champ) -->
<Style x:Key="AdminPlainRowButtonStyle" TargetType="Button">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="4,2">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Bouton « Ajouter un champ » d'un type de champ -->
<DataTemplate DataType="{x:Type afb:FeedbackFieldKindChoice}">
<Button Style="{StaticResource SmallButtonStyle}" Margin="0,0,8,8" Content="{Binding Label}"
ui:IconAssist.Icon="{Binding IconKey, Converter={StaticResource IconKey}}" Command="{Binding AddCommand}" />
</DataTemplate>
<!-- Choix d'une liste ou d'une case à cocher -->
<DataTemplate DataType="{x:Type afb:FeedbackOptionEditor}">
<Grid Margin="0,0,0,6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="60" ToolTip="{Binding Id}"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
<StackPanel Grid.Column="1" Orientation="Horizontal" Margin="6,0,0,0" VerticalAlignment="Center">
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Monter"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Descendre"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="28" Height="28" Padding="7" ToolTip="Retirer ce choix"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
</StackPanel>
</Grid>
</DataTemplate>
<!-- Champ d'un type de demande : ligne repliée (type, libellé, résumé) et réglages dépliés -->
<DataTemplate DataType="{x:Type afb:FeedbackFieldEditor}">
<Border CornerRadius="12" Padding="12,10" Margin="0,0,0,8" BorderThickness="1"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="28" Height="28" CornerRadius="8" VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding KindIconKey, Converter={StaticResource IconKey}}" />
</Border>
<Button Grid.Column="1" Style="{StaticResource AdminPlainRowButtonStyle}" Margin="6,0,8,0" HorizontalAlignment="Stretch"
Command="{Binding ToggleCommand}" ToolTip="Afficher / masquer les réglages">
<StackPanel>
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextPrimaryBrush}" />
<TextBlock Text="{Binding Summary}" FontSize="11.5" Margin="0,2,0,0" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource TextMutedBrush}" />
</StackPanel>
</Button>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Monter"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Descendre"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ToolTip="Retirer ce champ (les demandes déjà reçues le gardent)"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
</StackPanel>
</Grid>
<StackPanel Margin="38,12,0,2" Visibility="{Binding IsExpanded, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Libellé" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="80"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
<TextBlock Text="Aide (texte indicatif ou sous le champ, 4 lignes au plus, sans lien)" Style="{StaticResource AdminFieldLabelStyle}"
Margin="0,10,0,6" />
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="56" MaxHeight="110" MaxLength="200"
Text="{Binding Hint, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
<WrapPanel Margin="0,12,0,0">
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding Required, Mode=TwoWay}" Margin="0,0,24,8">
<TextBlock Text="{Binding RequiredText}" FontSize="12.5" />
</CheckBox>
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding ShowInList, Mode=TwoWay}" Margin="0,0,24,8"
Visibility="{Binding CanShowInList, Converter={StaticResource BoolToVisibility}}"
ToolTip="La valeur choisie s'affiche dans la liste « Demandes des joueurs » (un seul champ par type)">
<TextBlock Text="Aperçu dans la liste des demandes" FontSize="12.5" />
</CheckBox>
</WrapPanel>
<StackPanel Visibility="{Binding HasMaxLength, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding MaxLengthText}" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Width="140" HorizontalAlignment="Left" MaxLength="4"
Text="{Binding MaxLength, UpdateSourceTrigger=PropertyChanged, Delay=400}" />
</StackPanel>
<StackPanel Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Nombre d'étoiles" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="180" HorizontalAlignment="Left"
ItemsSource="{Binding RatingSizes}" SelectedItem="{Binding RatingMax, Mode=TwoWay}" DisplayMemberPath="Label" />
</StackPanel>
<StackPanel Visibility="{Binding HasOptions, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Choix (2 à 20, dans l'ordre affiché aux joueurs)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,4,0,6" />
<ItemsControl ItemsSource="{Binding Options}" Focusable="False" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Ajouter un choix" HorizontalAlignment="Left" Margin="0,2,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddOptionCommand}" />
</StackPanel>
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,10,0,0" TextWrapping="Wrap">
<Run Text="Identifiant :" /><Run Text=" " /><Run Text="{Binding Id, Mode=OneWay}" />
<Run Text="(fixe : il relie les réponses des joueurs à ce champ ; pour changer de type, ajoutez un autre champ)" />
</TextBlock>
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
<!-- Réglages d'un type de demande (colonne de droite) -->
<DataTemplate DataType="{x:Type afb:FeedbackFormEditor}">
<StackPanel>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<DockPanel Margin="0,0,0,12">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding Hidden, Mode=TwoWay}" Margin="0,0,14,0"
VerticalAlignment="Center" ToolTip="Un type masqué n'est plus proposé aux joueurs ; ses demandes restent consultables">
<TextBlock Text="Masqué" FontSize="12.5" />
</CheckBox>
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Monter (ordre affiché aux joueurs)"
ui:IconAssist.Icon="{StaticResource Icon.ArrowUp}" Command="{Binding MoveUpCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" ToolTip="Descendre"
ui:IconAssist.Icon="{StaticResource Icon.ArrowDown}" Command="{Binding MoveDownCommand}" />
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8"
ToolTip="Supprimer ce type (les demandes déjà reçues restent consultables)"
ui:IconAssist.Icon="{StaticResource Icon.Delete}" Command="{Binding RemoveCommand}" />
</StackPanel>
<TextBlock Text="{Binding OriginalLabel, StringFormat='Type de demande · {0}'}" Style="{StaticResource AdminCardTitleStyle}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis" />
</DockPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="200" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Nom affiché aux joueurs" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="40"
Text="{Binding Label, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
<StackPanel Grid.Column="1" Margin="10,0,0,0">
<TextBlock Text="Icône d'origine" Style="{StaticResource AdminFieldLabelStyle}" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" ItemsSource="{Binding Icons}" SelectedItem="{Binding Icon, Mode=TwoWay}"
IsEnabled="{Binding UsesDefaultIcon}">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Path Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" VerticalAlignment="Center"
Data="{Binding Value, Converter={StaticResource IconKey}}" />
<TextBlock Text="{Binding Label}" Margin="8,0,0,0" VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</Grid>
<!-- Icône importée : fichier .ico obligatoire, image 64 × 64 convertie en PNG sans métadonnées -->
<DockPanel Margin="0,10,0,0">
<Border DockPanel.Dock="Left" Width="44" Height="44" CornerRadius="12" Background="{DynamicResource BadgeBrush}" Margin="0,0,12,0">
<Grid>
<Path Width="20" Height="20" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Image Width="32" Height="32" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding CustomIconBytes, Converter={StaticResource AdminBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource SmallButtonStyle}" Content="Importer un fichier .ico…" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.FolderOpen}" Command="{Binding ImportIconCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Retirer l'icône importée"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding RemoveIconCommand}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" VerticalAlignment="Center" TextWrapping="Wrap"
Text="Ou une icône à vous : fichier .ico uniquement, contenant une image de 64 × 64 pixels (PNG ou couleurs 24/32 bits). Elle est convertie en PNG sans métadonnées et signée avec les formulaires." />
</DockPanel>
<TextBlock Text="Description (sous le nom, dans le choix du type)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="120"
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
<TextBlock Text="Exemple de titre (texte indicatif du champ « Titre »)" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,10,0,6" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="80"
Text="{Binding TitleHint, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding TechByDefault, Mode=TwoWay}" Margin="0,12,0,0">
<TextBlock Text="« Joindre les informations techniques » coché par défaut" FontSize="12.5" />
</CheckBox>
<CheckBox Style="{StaticResource ToggleSwitchStyle}" IsChecked="{Binding AllowAttachments, Mode=TwoWay}" Margin="0,10,0,0"
ToolTip="3 captures au plus : vraie image exigée, PNG ou JPEG, sans métadonnées (contrôlées par le launcher et le serveur)">
<TextBlock Text="Captures d'écran autorisées (3 au plus)" FontSize="12.5" />
</CheckBox>
</StackPanel>
</Border>
<Border Style="{StaticResource AdminCardStyle}">
<StackPanel>
<TextBlock Style="{StaticResource AdminCardTitleStyle}" Margin="0,0,0,4">
<Run Text="Champs" /><Run Text=" · " /><Run Text="{Binding FieldsText, Mode=OneWay}" />
</TextBlock>
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="12" Margin="0,0,0,12" TextWrapping="Wrap"
Text="Le titre de la demande, les captures d'écran et les informations techniques sont toujours proposés. Cliquez sur un champ pour le régler ; 15 champs au plus." />
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
<TextBlock Text="Ajouter un champ" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,8,0,8" />
<ItemsControl ItemsSource="{Binding FieldKinds}" Focusable="False" IsEnabled="{Binding AddFieldsEnabled}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type afb:FeedbackFormsPanelViewModel}">
<!-- Deux colonnes qui défilent séparément (HasSplitLayout) : types à gauche, réglages et aperçu à droite. -->
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasConflict, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Button DockPanel.Dock="Right" Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="12,0,0,0"
VerticalAlignment="Center" ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
<TextBlock TextWrapping="Wrap" FontSize="12.5" VerticalAlignment="Center"
Text="Les formulaires ont été publiés par un autre administrateur entre-temps : rechargez-les, puis refaites vos modifications." />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<TextBlock TextWrapping="Wrap" FontSize="12.5"
Text="Publication non configurée sur le serveur : les formulaires sont écrits à côté du trousseau public (LauncherApi:Catalog:PublicFile ou LauncherApi:Keyring:PublicFile). Voir server/deploy/LISEZMOI.md, étape 3 nonies." />
</Border>
<!-- État publié et actions -->
<Border Style="{StaticResource AdminCardStyle}" Padding="16,14">
<DockPanel>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" VerticalAlignment="Center">
<Button Style="{StaticResource SmallButtonStyle}" Content="Revenir à l'origine" Margin="0,0,8,0"
ToolTip="Noms et champs d'origine des quatre types (à publier ensuite)"
ui:IconAssist.Icon="{StaticResource Icon.Restore}" Command="{Binding RestoreOriginalCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Annuler les modifications" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}" Command="{Binding DiscardCommand}" />
<Button Style="{StaticResource SmallButtonStyle}" Content="Recharger" Margin="0,0,8,0"
ui:IconAssist.Icon="{StaticResource Icon.Refresh}" Command="{Binding ReloadCommand}" />
<Button Style="{StaticResource AccentButtonStyle}" Content="Publier"
ui:IconAssist.Icon="{StaticResource Icon.CloudUpload}" Command="{Binding PublishCommand}" />
</StackPanel>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding PublishedText}" FontSize="12.5" FontWeight="SemiBold" TextWrapping="Wrap" />
<TextBlock Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,3,0,0" TextWrapping="Wrap"
Text="Communs à tous les jeux. Signés avec la clé de ce PC (autorisation « formulaires » du trousseau) ; les launchers les vérifient avant de les afficher." />
</StackPanel>
</DockPanel>
</Border>
<!-- Contrôles en direct -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource DangerBrush}"
Visibility="{Binding HasValidationErrors, Converter={StaticResource BoolToVisibility}}">
<StackPanel>
<TextBlock Text="À corriger avant de publier :" FontSize="12.5" FontWeight="SemiBold" Margin="0,0,0,4" />
<ItemsControl ItemsSource="{Binding ValidationErrors}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding StringFormat='• {0}'}" FontSize="12" TextWrapping="Wrap" Margin="0,1,0,0" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
<ItemsControl ItemsSource="{Binding Warnings}" Focusable="False">
<ItemsControl.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,1,0,1">
<Path DockPanel.Dock="Left" Width="14" Height="14" Stretch="Uniform" Fill="{DynamicResource WarningBrush}"
Data="{StaticResource Icon.Alert}" VerticalAlignment="Top" Margin="0,1,8,0" />
<TextBlock Text="{Binding}" FontSize="12" TextWrapping="Wrap" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="260" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="420" />
</Grid.ColumnDefinitions>
<DockPanel>
<TextBlock DockPanel.Dock="Top" Text="{Binding FormsText}" Style="{StaticResource SectionTitleStyle}" Margin="2,0,0,8" />
<Button DockPanel.Dock="Bottom" Style="{StaticResource SmallButtonStyle}" Content="Ajouter un type de demande" Margin="0,4,8,0"
HorizontalAlignment="Left" ui:IconAssist.Icon="{StaticResource Icon.Plus}" Command="{Binding AddFormCommand}" />
<ScrollViewer VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,8,0">
<ListBox ItemsSource="{Binding Forms}" SelectedItem="{Binding SelectedForm, Mode=TwoWay}"
Style="{StaticResource AdminListBoxStyle}" IsEnabled="{Binding IsEditable}"
ItemContainerStyle="{StaticResource AdminFeedbackFormItemStyle}" />
</ScrollViewer>
</DockPanel>
<!-- Réglages du type sélectionné (modèle FeedbackFormEditor ci-dessus) -->
<ScrollViewer Grid.Column="1" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,10,0"
ui:ScrollAssist.ResetOn="{Binding SelectedForm}">
<ContentControl Content="{Binding SelectedForm}" Focusable="False" IsEnabled="{Binding IsEditable}" />
</ScrollViewer>
<!-- Aperçu : mêmes modèles que la fenêtre « Aide » (Themes/FeedbackForms.xaml) ; rien n'est envoyé -->
<ScrollViewer Grid.Column="2" Margin="10,0,0,0" VerticalScrollBarVisibility="Auto" Focusable="False" Padding="0,0,14,0">
<Border CornerRadius="18" Padding="20,18" BorderThickness="1" Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource BorderBrush}">
<StackPanel>
<TextBlock Text="{Binding PreviewTitle}" FontSize="15" FontWeight="SemiBold" Margin="0,0,0,4" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding PreviewNote}" Style="{StaticResource AdminHintStyle}" FontSize="11.5" Margin="0,0,0,14" TextWrapping="Wrap" />
<TextBlock Text="Titre *" Style="{StaticResource FeedbackLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" Margin="0,0,0,14" ui:TextAssist.Placeholder="{Binding PreviewTitleHint}" />
<ItemsControl ItemsSource="{Binding Preview}" Focusable="False" />
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding PreviewTechByDefault, Mode=OneWay}" IsHitTestVisible="False">
<TextBlock Text="Joindre les informations techniques" TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
</Border>
</ScrollViewer>
</Grid>
</DockPanel>
</DataTemplate>
<!-- ============================================================ Page « Signature » (phase 7) -->
<!-- Clé du trousseau proposé -->
+167
View File
@@ -0,0 +1,167 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:GameLauncher.Controls"
xmlns:fb="clr-namespace:GameLauncher.Modules.Feedback;assembly=GameLauncher.Modules.Feedback">
<!--
Champs des formulaires des demandes (bouton « Aide ») : zone de texte, texte court, liste déroulante, cases à
cocher, case oui/non, note en étoiles. Modèles PARTAGÉS par la fenêtre « Aide » des joueurs (Views/FeedbackWindow)
et l'aperçu de la page « Formulaires des demandes » du menu d'administration (Themes/Admin.xaml) : l'aperçu
montre exactement ce que verront les joueurs. ViewModels : GameLauncher.Modules.Feedback.
-->
<!-- Étoile d'une note : bouton sans cadre -->
<Style x:Key="FeedbackStarButtonStyle" TargetType="Button">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Padding" Value="3" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FeedbackLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<Style x:Key="FeedbackFieldErrorStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Margin" Value="2,5,0,0" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</Style>
<!-- Case à cocher arrondie (cases à cocher et case oui/non des formulaires) -->
<Style x:Key="FeedbackCheckBoxStyle" TargetType="CheckBox">
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Margin" Value="0,3" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="CheckBox">
<Grid Background="Transparent">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border x:Name="Box" Width="20" Height="20" CornerRadius="6" BorderThickness="1.5" VerticalAlignment="Top"
Margin="0,1,0,0" Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderStrongBrush}">
<Path x:Name="Mark" Width="11" Height="11" Stretch="Uniform" Data="{StaticResource Icon.Check}"
Fill="{DynamicResource OnAccentBrush}" Visibility="Collapsed" />
</Border>
<ContentPresenter Grid.Column="1" Margin="10,0,0,0" VerticalAlignment="Center" RecognizesAccessKey="False" />
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="Box" Property="Background" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
<Setter TargetName="Mark" Property="Visibility" Value="Visible" />
</Trigger>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Box" Property="BorderBrush" Value="{DynamicResource AccentBrush}" />
</Trigger>
<Trigger Property="IsKeyboardFocused" Value="True">
<Setter TargetName="Box" Property="BorderThickness" Value="2.5" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.5" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<DataTemplate DataType="{x:Type fb:FeedbackCheckOption}">
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding IsChecked}">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" />
</CheckBox>
</DataTemplate>
<DataTemplate DataType="{x:Type fb:RatingStar}">
<Button Style="{StaticResource FeedbackStarButtonStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding ToolTip}">
<Path x:Name="StarIcon" Width="26" Height="26" Stretch="Uniform" Data="{StaticResource Icon.Star}"
Fill="{DynamicResource BorderStrongBrush}" />
</Button>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsOn}" Value="True">
<Setter TargetName="StarIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!--
Champ du formulaire (une seule partie visible selon son type) : zone de texte, texte court, liste déroulante,
cases à cocher, case oui/non ou note en étoiles (3, 5 ou 10).
-->
<DataTemplate DataType="{x:Type fb:FeedbackFieldViewModel}">
<StackPanel Margin="0,0,0,14">
<TextBlock Text="{Binding Label}" Style="{StaticResource FeedbackLabelStyle}" TextWrapping="Wrap"
Visibility="{Binding ShowLabel, Converter={StaticResource BoolToVisibility}}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
ui:TextAssist.Placeholder="{Binding Hint}" Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsText, Converter={StaticResource BoolToVisibility}}" />
<ItemsControl ItemsSource="{Binding CheckOptions}" Focusable="False"
Visibility="{Binding IsCheckboxes, Converter={StaticResource BoolToVisibility}}" />
<CheckBox Style="{StaticResource FeedbackCheckBoxStyle}" IsChecked="{Binding IsChecked}"
Visibility="{Binding IsYesNo, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding Label}" TextWrapping="Wrap" FontWeight="SemiBold" />
</CheckBox>
<Grid Visibility="{Binding IsMultiline, Converter={StaticResource BoolToVisibility}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="84" MaxHeight="180"
MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
ui:TextAssist.Placeholder="{Binding Hint}"
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="1" Text="{Binding LengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
Foreground="{DynamicResource TextMutedBrush}" />
</Grid>
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="380" HorizontalAlignment="Left" MinWidth="260"
ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption, Mode=TwoWay}" DisplayMemberPath="Label"
Visibility="{Binding IsChoice, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
<ItemsControl ItemsSource="{Binding Stars}" Focusable="False" Margin="-3,0,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal" MaxWidth="400" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Text="{Binding RatingText}" FontSize="12.5" Margin="10,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<!-- Aide des champs sans zone de saisie (dans une zone de texte, elle sert de texte indicatif) -->
<TextBlock Text="{Binding Hint}" FontSize="12" Margin="2,4,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextMutedBrush}"
Visibility="{Binding ShowHintText, Converter={StaticResource BoolToVisibility}}" />
<TextBlock Text="{Binding Error}" Style="{StaticResource FeedbackFieldErrorStyle}"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
</ResourceDictionary>
+30 -90
View File
@@ -62,91 +62,10 @@
</Setter>
</Style>
<!-- Étoile d'une note : bouton sans cadre -->
<Style x:Key="FeedbackStarButtonStyle" TargetType="Button">
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FocusVisualStyle" Value="{x:Null}" />
<Setter Property="Padding" Value="3" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="Bd" Background="Transparent" CornerRadius="8" Padding="{TemplateBinding Padding}">
<ContentPresenter />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource SurfaceHoverBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<Style x:Key="FeedbackLabelStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12.5" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="Margin" Value="0,0,0,6" />
<Setter Property="Foreground" Value="{DynamicResource TextSecondaryBrush}" />
</Style>
<Style x:Key="FeedbackFieldErrorStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Margin" Value="2,5,0,0" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Foreground" Value="{DynamicResource DangerBrush}" />
</Style>
<DataTemplate DataType="{x:Type fb:RatingStar}">
<Button Style="{StaticResource FeedbackStarButtonStyle}" Command="{Binding SelectCommand}" ToolTip="{Binding ToolTip}">
<Path x:Name="StarIcon" Width="26" Height="26" Stretch="Uniform" Data="{StaticResource Icon.Star}"
Fill="{DynamicResource BorderStrongBrush}" />
</Button>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsOn}" Value="True">
<Setter TargetName="StarIcon" Property="Fill" Value="{DynamicResource WarningBrush}" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
<!-- Champ du formulaire : texte, liste de choix ou note (une seule partie visible) -->
<DataTemplate DataType="{x:Type fb:FeedbackFieldViewModel}">
<StackPanel Margin="0,0,0,14">
<TextBlock Text="{Binding Label}" Style="{StaticResource FeedbackLabelStyle}" />
<Grid Visibility="{Binding IsMultiline, Converter={StaticResource BoolToVisibility}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBox Style="{StaticResource ModernMultilineTextBoxStyle}" MinHeight="84" MaxHeight="180"
MaxLength="{Binding MaxLength}" SpellCheck.IsEnabled="True"
ui:TextAssist.Placeholder="{Binding Hint}"
Text="{Binding Text, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Grid.Row="1" Text="{Binding LengthText}" FontSize="11" Margin="0,4,4,0" HorizontalAlignment="Right"
Foreground="{DynamicResource TextMutedBrush}" />
</Grid>
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="380" HorizontalAlignment="Left" MinWidth="260"
ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption, Mode=TwoWay}" DisplayMemberPath="Label"
Visibility="{Binding IsChoice, Converter={StaticResource BoolToVisibility}}" />
<StackPanel Orientation="Horizontal" Visibility="{Binding IsRating, Converter={StaticResource BoolToVisibility}}">
<ItemsControl ItemsSource="{Binding Stars}" Focusable="False" Margin="-3,0,0,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<TextBlock Text="{Binding RatingText}" FontSize="12.5" Margin="10,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<TextBlock Text="{Binding Error}" Style="{StaticResource FeedbackFieldErrorStyle}"
Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}" />
</StackPanel>
</DataTemplate>
<!--
Champs des formulaires (zone de texte, texte court, liste, cases à cocher, case oui/non, étoiles) : modèles
partagés avec l'aperçu de la page « Formulaires des demandes » du menu, dans Themes/FeedbackForms.xaml.
-->
</Window.Resources>
<Grid Margin="20">
@@ -167,8 +86,15 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Border Width="48" Height="48" CornerRadius="14" Background="{DynamicResource AccentGradientBrush}">
<Path Data="{Binding FormIconKey, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
Width="22" Height="22" Stretch="Uniform" />
<Grid>
<Path Data="{Binding FormIconKey, Converter={StaticResource IconKey}}" Fill="{DynamicResource OnAccentBrush}"
Width="22" Height="22" Stretch="Uniform"
Visibility="{Binding HasFormCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<!-- Icône importée depuis le menu (PNG 64 × 64 vérifié avec les formulaires signés) -->
<Image Width="30" Height="30" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding FormCustomIcon, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasFormCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0" VerticalAlignment="Center">
<TextBlock Text="{Binding FormTitle}" FontSize="19" FontWeight="SemiBold" />
@@ -186,6 +112,14 @@
<!-- ============================================ Étape 1 : type de demande -->
<StackPanel Visibility="{Binding IsChoosingKind, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Que souhaitez-vous nous transmettre ?" FontSize="14" FontWeight="SemiBold" Margin="0,0,0,12" />
<!-- Lecture des formulaires publiés (quelques secondes au plus, sinon formulaires d'origine) -->
<StackPanel Orientation="Horizontal" Margin="2,4,0,16"
Visibility="{Binding IsLoadingForms, Converter={StaticResource BoolToVisibility}}">
<ProgressBar Style="{StaticResource ModernProgressBarStyle}" Width="90" Height="4" IsIndeterminate="True"
VerticalAlignment="Center" />
<TextBlock Text="Chargement des formulaires…" FontSize="12.5" Margin="12,0,0,0" VerticalAlignment="Center"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
<ItemsControl ItemsSource="{Binding Kinds}" Focusable="False" Margin="0,0,-12,0">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -201,8 +135,14 @@
Data="{StaticResource Icon.ChevronRight}" />
<Border DockPanel.Dock="Left" Width="40" Height="40" CornerRadius="12" Margin="0,0,14,0"
VerticalAlignment="Center" Background="{DynamicResource BadgeBrush}">
<Path Width="19" Height="19" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
<Grid>
<Path Width="19" Height="19" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<Image Width="26" Height="26" Stretch="Uniform" RenderOptions.BitmapScalingMode="HighQuality"
Source="{Binding CustomIcon, Converter={StaticResource FeedbackBytesToImage}, ConverterParameter=64}"
Visibility="{Binding HasCustomIcon, Converter={StaticResource BoolToVisibility}}" />
</Grid>
</Border>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" FontSize="14" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
@@ -250,7 +190,7 @@
<ItemsControl ItemsSource="{Binding Fields}" Focusable="False" />
<!-- Captures d'écran : converties et nettoyées de leurs métadonnées avant l'envoi -->
<StackPanel Margin="0,0,0,14" Visibility="{Binding ScreenshotsAvailable, Converter={StaticResource BoolToVisibility}}">
<StackPanel Margin="0,0,0,14" Visibility="{Binding AttachmentsAllowed, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="Captures d'écran (facultatif)" Style="{StaticResource FeedbackLabelStyle}" />
<StackPanel Orientation="Horizontal">
<Button Style="{StaticResource SecondaryButtonStyle}" Content="Ajouter une image…" Margin="0,0,10,0"
@@ -0,0 +1,217 @@
using GameLauncher.Contracts.Feedback;
using GameLauncher.Modules;
using GameLauncher.Modules.Admin;
using GameLauncher.Modules.Admin.Feedback;
using GameLauncher.Modules.Admin.ViewModels;
using GameLauncher.Tests.Contracts;
using GameLauncher.Tests.Infrastructure;
namespace GameLauncher.Tests.Admin;
/// <summary>Page « Formulaires des demandes » du menu : modification, contrôles en direct, aperçu, publication signée.</summary>
public class FeedbackFormsPanelTests
{
private static readonly ModuleCatalog Modules = new(new ILauncherModule[] { new AdminModule(), new FeedbackAdminModule() });
private static async Task<(TestApi Api, string PublicDirectory)> StartApiAsync()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-forms-panel", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
["LauncherApi:Feedback:Enabled"] = "true",
["LauncherApi:Feedback:Games:0"] = "EZR"
});
return (api, directory);
}
private static async Task<(AdminShellViewModel Shell, FeedbackFormsPanelViewModel Page)> OpenAsync(TestApi api, string folder, FakeDialogs dialogs)
{
var (_, signer) = AdminTestKit.CreateKeyFile(folder);
AdminTestKit.RegisterOwner(api, signer);
signer.Dispose();
var shell = new AdminShellViewModel(AdminTestKit.Environment(api, folder, dialogs, Modules));
await Assert.IsType<LoginViewModel>(shell.Screen).LoginAsync(AdminTestKit.Passphrase);
var workspace = Assert.IsType<AdminWorkspaceViewModel>(shell.Screen);
workspace.Navigate(FeedbackFormsPanelViewModel.PanelId);
await workspace.Activation;
return (shell, Assert.IsType<FeedbackFormsPanelViewModel>(workspace.SelectedPanel!.ViewModel));
}
[Fact]
public async Task Forms_are_edited_checked_previewed_and_published_signed()
{
var (api, directory) = await StartApiAsync();
await using var _ = api;
using var folder = new TempFolder();
var dialogs = new FakeDialogs();
var (shell, page) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
// Formulaires d'origine : rien de modifié, rien à publier.
Assert.Equal(4, page.Forms.Count);
Assert.Contains("Formulaires d'origine", page.PublishedText);
Assert.False(page.IsDirty);
Assert.Empty(page.ValidationErrors);
Assert.False(page.PublishCommand.CanExecute(null));
Assert.All(page.Forms, f => Assert.False(f.IsDirty));
// Bug : renommé, un champ retiré, des cases à cocher et une case oui/non ajoutées ; l'aperçu suit.
var bug = page.Forms.Single(f => f.Kind == FeedbackKinds.Bug);
page.SelectedForm = bug;
bug.Label = "Problème technique";
bug.Fields.Single(f => f.Id == "expected").RemoveCommand.Execute(null);
bug.FieldKinds.Single(k => k.Kind == FeedbackFieldKind.Checkboxes).AddCommand.Execute(null);
var where = bug.Fields.Last();
Assert.True(where.IsExpanded && where.IsNew && where.HasOptions);
where.Label = "Où cela arrive-t-il ?";
where.Required = true;
where.AddOptionCommand.Execute(null);
where.Options[0].Label = "Partie solo";
where.Options[1].Label = "Coopératif";
where.Options[2].Label = "Menus";
where.MoveUpCommand.Execute(null);
bug.FieldKinds.Single(k => k.Kind == FeedbackFieldKind.YesNo).AddCommand.Execute(null);
bug.Fields.Last().Label = "Le problème revient après un redémarrage";
Assert.NotEqual(where.Id, bug.Fields.Last().Id);
Assert.True(page.IsDirty);
Assert.True(bug.IsDirty);
Assert.False(page.Forms.Single(f => f.Kind == FeedbackKinds.Review).IsDirty);
Assert.Equal(bug.Fields.Count, page.Preview.Count);
Assert.Equal(3, page.Preview.Single(p => p.Id == where.Id).CheckOptions.Count);
Assert.True(page.Preview.Last().IsYesNo);
// Avis : note sur 10 ; « autre » masqué.
var review = page.Forms.Single(f => f.Kind == FeedbackKinds.Review);
var rating = review.Fields.Single(f => f.Id == "rating");
rating.RatingMax = rating.RatingSizes.Single(c => c.Value == "10");
page.Forms.Single(f => f.Kind == FeedbackKinds.Other).Hidden = true;
// Contrôles en direct : un lien bloque la publication, une donnée personnelle avertit seulement.
bug.Description = "Plus d'infos sur www.exemple.com";
Assert.Contains(page.ValidationErrors, e => e.Contains("liens"));
Assert.False(page.PublishCommand.CanExecute(null));
bug.Description = "Un problème technique dans le jeu.";
bug.Fields.Last().Hint = "Ou laissez votre adresse e-mail";
Assert.Empty(page.ValidationErrors);
Assert.Contains(page.Warnings, w => w.Contains("e-mail"));
bug.Fields.Last().Hint = "";
Assert.DoesNotContain(page.Warnings, w => w.Contains("e-mail"));
Assert.True(page.PublishCommand.CanExecute(null));
// Publication signée.
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
Assert.Contains("révision 1", page.StatusMessage);
Assert.False(page.IsDirty);
Assert.Contains("Révision 1 publiée", page.PublishedText);
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
Assert.Equal("Problème technique", published.Find(FeedbackKinds.Bug)!.Label);
Assert.True(published.Find(FeedbackKinds.Other)!.Hidden);
Assert.Equal(10, published.Find(FeedbackKinds.Review)!.Field("rating")!.RatingMax);
Assert.Equal(new[] { "Partie solo", "Coopératif", "Menus" },
published.Find(FeedbackKinds.Bug)!.Fields.Single(f => f.Kind == FeedbackFieldKind.Checkboxes).Choices.Select(o => o.Label).ToArray());
// Annuler, revenir à l'origine (non publié tant qu'on ne publie pas).
page.Forms.Single(f => f.Kind == FeedbackKinds.Feature).Label = "Idée";
Assert.True(page.IsDirty);
page.DiscardCommand.Execute(null);
Assert.False(page.IsDirty);
page.RestoreOriginalCommand.Execute(null);
Assert.True(page.IsDirty);
Assert.Equal("Bug", page.Forms.Single(f => f.Kind == FeedbackKinds.Bug).Label);
Assert.False(page.Forms.Single(f => f.Kind == FeedbackKinds.Other).Hidden);
}
[Fact]
public async Task A_publication_by_someone_else_in_between_is_never_overwritten()
{
var (api, _) = await StartApiAsync();
await using var _ = api;
using var folder = new TempFolder();
var (shell, page) = await OpenAsync(api, folder.Path, new FakeDialogs());
await using var __ = shell;
// Un autre administrateur publie pendant la modification.
using var otherKey = api.AddOwner();
await using (var other = await api.LoginAsync(otherKey))
await other.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
page.Forms.Single(f => f.Kind == FeedbackKinds.Feature).Label = "Idée";
await page.PublishCommand.ExecuteAsync();
Assert.True(page.HasConflict);
Assert.True(page.HasError);
Assert.False(page.PublishCommand.CanExecute(null));
await page.ReloadCommand.ExecuteAsync(); // modifications abandonnées (confirmation acceptée)
Assert.False(page.HasConflict);
Assert.Equal(FeedbackFormsKit.NewBugLabel, page.Forms.Single(f => f.Kind == FeedbackKinds.Bug).Label);
Assert.False(page.IsDirty);
}
[Fact]
public async Task Categories_are_added_reordered_removed_and_get_an_icon_only_from_a_64_pixel_ico()
{
var (api, directory) = await StartApiAsync();
await using var _ = api;
using var folder = new TempFolder();
var dialogs = new FakeDialogs();
var (shell, page) = await OpenAsync(api, folder.Path, dialogs);
await using var __ = shell;
// Ajout : nouveau type sélectionné, avec un champ « Message », placé à la fin.
page.AddFormCommand.Execute(null);
var added = page.SelectedForm!;
Assert.True(added.IsNew);
Assert.Equal("type_1", added.Kind);
Assert.Equal(5, page.Forms.Count);
Assert.Same(added, page.Forms[^1]);
added.Label = "Suggestion de niveau";
added.AllowAttachments = false;
Assert.Contains("non proposées", page.PreviewNote);
// Icône : seul un .ico avec une image 64 × 64 est accepté.
var wrong = Path.Combine(folder.Path, "icone.png");
File.WriteAllBytes(wrong, FeedbackTestImages.PngWith("tEXt", "x"));
dialogs.NextOpenFile = wrong;
added.ImportIconCommand.Execute(null);
Assert.False(added.HasCustomIcon);
Assert.Contains(dialogs.Messages, m => m.Contains("Seuls les fichiers .ico"));
var small = Path.Combine(folder.Path, "petite.ico");
File.WriteAllBytes(small, FeedbackFormsKit.Ico(32));
dialogs.NextOpenFile = small;
added.ImportIconCommand.Execute(null);
Assert.False(added.HasCustomIcon);
Assert.Contains(dialogs.Messages, m => m.Contains("64 × 64"));
var good = Path.Combine(folder.Path, "niveau.ico");
File.WriteAllBytes(good, FeedbackFormsKit.Ico());
dialogs.NextOpenFile = good;
added.ImportIconCommand.Execute(null);
Assert.True(added.HasCustomIcon);
Assert.False(added.UsesDefaultIcon);
Assert.Null(FeedbackIcons.CheckCustom(added.CustomIcon));
// Ordre : le nouveau type en premier ; suppression d'un type d'origine (confirmation).
for (var i = 0; i < 4; i++) added.MoveUpCommand.Execute(null);
Assert.Same(added, page.Forms[0]);
page.Forms.Single(f => f.Kind == FeedbackKinds.Other).RemoveCommand.Execute(null);
Assert.Equal(4, page.Forms.Count);
Assert.Empty(page.ValidationErrors);
await page.PublishCommand.ExecuteAsync();
Assert.False(page.HasError, page.ErrorMessage);
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
Assert.Equal(new[] { "type_1", FeedbackKinds.Review, FeedbackKinds.Feature, FeedbackKinds.Bug },
published.Forms.Select(f => f.Kind).ToArray());
Assert.False(published.Forms[0].AllowAttachments);
Assert.NotNull(published.Forms[0].CustomIcon);
Assert.False(page.Forms[0].IsNew); // publié
// Huit types au plus ; « Revenir à l'origine » rétablit les quatre types d'origine.
while (page.AddFormCommand.CanExecute(null)) page.AddFormCommand.Execute(null);
Assert.Equal(FeedbackFormRules.MaxForms, page.Forms.Count);
page.RestoreOriginalCommand.Execute(null);
Assert.Equal(FeedbackForms.All.Select(f => f.Kind), page.Forms.Select(f => f.Kind));
}
}
@@ -182,7 +182,7 @@ public class FeedbackPanelTests
public void Feedback_audit_actions_have_friendly_labels()
{
foreach (var action in new[] { "feedback.submit", "feedback.attachment", "feedback.view", "feedback.update", "feedback.delete",
"feedback.purge", "audit.purge", "keyring.publish", "release.sign" })
"feedback.purge", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
}
@@ -0,0 +1,460 @@
using System.Security.Cryptography;
using System.Text;
using GameLauncher.ApiClient;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Signing;
using GameLauncher.Core.Trust;
using GameLauncher.Modules.Feedback;
using GameLauncher.Tests.Admin;
using GameLauncher.Tests.Signing;
namespace GameLauncher.Tests.Contracts;
/// <summary>Formulaires de démonstration modifiés « depuis le menu » (tous les types de champs).</summary>
internal static class FeedbackFormsKit
{
public const string NewBugLabel = "Problème technique";
/// <summary>
/// Formulaires d'origine modifiés : bug renommé, « résultat attendu » retiré, cases à cocher, texte court et case
/// oui/non ajoutés ; avis noté sur 10 ; « autre » masqué.
/// </summary>
public static List<FeedbackForm> Custom(string bugLabel = NewBugLabel)
{
return FeedbackForms.All.Select(form => form.Kind switch
{
FeedbackKinds.Bug => form with
{
Label = bugLabel,
Fields = form.Fields.Where(f => f.Id != "expected")
.Append(new FeedbackField("platform", "Où cela arrive-t-il ?", FeedbackFieldKind.Checkboxes, Required: true,
Options: new[] { new FeedbackOption("solo", "Partie solo"), new FeedbackOption("coop", "Coopératif"), new FeedbackOption("menu", "Menus") }))
.Append(new FeedbackField("version_seen", "Version affichée en jeu", FeedbackFieldKind.Text, Required: false,
"En bas à droite de l'écran titre.", MaxLength: 40))
.Append(new FeedbackField("again", "Le problème revient après un redémarrage", FeedbackFieldKind.YesNo, Required: false))
.ToList()
},
FeedbackKinds.Review => form with
{
Fields = form.Fields.Select(f => f.Id == "rating" ? f with { RatingMax = 10 } : f).ToList()
},
FeedbackKinds.Other => form with { Hidden = true },
_ => form
}).ToList();
}
/// <summary>Type de demande ajouté depuis le menu (un champ « Message »).</summary>
public static FeedbackForm NewKind(string kind, string label = "Suggestion de niveau", bool attachments = true, string? icon = null) =>
new(kind, label, "Proposez un niveau.", "Lightbulb", "", TechByDefault: false,
new[] { new FeedbackField("message", "Message", FeedbackFieldKind.Multiline, Required: true) },
AllowAttachments: attachments, CustomIcon: icon);
/// <summary>Fichier .ico : une image bitmap (BITMAPINFOHEADER) de <paramref name="size"/> pixels en 32, 24 ou 8 bits.</summary>
public static byte[] Ico(int size = 64, int bits = 32, int count = 1)
{
var stride = (size * bits / 8 + 3) & ~3;
var maskStride = (size + 31) / 32 * 4;
var dib = new byte[40 + stride * size + maskStride * size];
BitConverter.GetBytes(40).CopyTo(dib, 0);
BitConverter.GetBytes(size).CopyTo(dib, 4);
BitConverter.GetBytes(size * 2).CopyTo(dib, 8);
BitConverter.GetBytes((ushort)1).CopyTo(dib, 12);
BitConverter.GetBytes((ushort)bits).CopyTo(dib, 14);
var random = new Random(size + bits);
for (var i = 40; i < 40 + stride * size; i++) dib[i] = (byte)random.Next(256);
return IcoOf(Enumerable.Repeat((size, dib), count).ToArray());
}
/// <summary>Fichier .ico avec les images données (bitmap ou PNG).</summary>
public static byte[] IcoOf(params (int Size, byte[] Data)[] images)
{
using var stream = new MemoryStream();
var writer = new BinaryWriter(stream);
writer.Write((ushort)0);
writer.Write((ushort)1);
writer.Write((ushort)images.Length);
var offset = 6 + 16 * images.Length;
foreach (var (size, data) in images)
{
writer.Write((byte)(size >= 256 ? 0 : size));
writer.Write((byte)(size >= 256 ? 0 : size));
writer.Write((byte)0);
writer.Write((byte)0);
writer.Write((ushort)1);
writer.Write((ushort)32);
writer.Write(data.Length);
writer.Write(offset);
offset += data.Length;
}
foreach (var (_, data) in images) writer.Write(data);
return stream.ToArray();
}
public static FeedbackFormsDocument Document(long revision, List<FeedbackForm>? forms = null) =>
new() { Revision = revision, Forms = forms ?? Custom() };
public static FeedbackFormsDocument Signed(ECDsa key, long revision, List<FeedbackForm>? forms = null, DateTimeOffset? at = null)
{
var document = Document(revision, forms);
return document with
{
Signature = SigningKit.Sign(key, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(document), at)
};
}
public static byte[] Bytes(FeedbackFormsDocument document) => Encoding.UTF8.GetBytes(FeedbackFormRules.Serialize(document));
}
/// <summary>Formulaires des demandes publiés depuis le menu : règles, signature, nouveaux types de champs, lecture par le launcher.</summary>
public class FeedbackFormsTests
{
[Fact]
public void Original_forms_are_valid_and_their_signed_digest_survives_a_round_trip()
{
var errors = new List<string>();
var document = FeedbackFormsKit.Document(1, FeedbackForms.All.ToList());
Assert.True(FeedbackFormRules.Validate(document, errors), string.Join(" ", errors));
Assert.True(FeedbackFormRules.Validate(FeedbackFormsKit.Document(1), errors), string.Join(" ", errors));
var custom = FeedbackFormsKit.Document(3);
var reread = FeedbackFormRules.Parse(FeedbackFormsKit.Bytes(custom with { UpdatedAt = DateTimeOffset.UtcNow }))!;
Assert.Equal(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(reread));
Assert.Contains("\"checkboxes\"", FeedbackFormRules.Serialize(custom), StringComparison.OrdinalIgnoreCase);
// Tout changement visible change le condensé (révision, libellé, ordre, masquage).
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(custom with { Revision = 4 }));
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(FeedbackFormsKit.Document(3, FeedbackFormsKit.Custom("Bogue"))));
var hidden = FeedbackFormsKit.Custom();
hidden[0] = hidden[0] with { Hidden = true };
Assert.NotEqual(FeedbackFormRules.Digest(custom), FeedbackFormRules.Digest(FeedbackFormsKit.Document(3, hidden)));
}
[Fact]
public void Forms_out_of_bounds_or_with_links_are_refused()
{
List<FeedbackForm> With(string kind, Func<FeedbackForm, FeedbackForm> change) =>
FeedbackFormsKit.Custom().Select(f => f.Kind == kind ? change(f) : f).ToList();
FeedbackForm AddField(FeedbackForm form, FeedbackField field) => form with { Fields = form.Fields.Append(field).ToList() };
var cases = new (string Expected, List<FeedbackForm> Forms)[]
{
("liens", With(FeedbackKinds.Bug, f => f with { Description = "Voir www.exemple.com" })),
("liens", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Champ", FeedbackFieldKind.Text, false, "Allez sur hxxps://site[.]ru")))),
("liens", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Liste", FeedbackFieldKind.Choice, false,
Options: new[] { new FeedbackOption("a", "discord.gg/abc"), new FeedbackOption("b", "B") })))),
("identifiant", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("Mauvais Id", "Champ", FeedbackFieldKind.Text, false)))),
("en double", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("steps", "Encore", FeedbackFieldKind.Text, false)))),
("choix", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Liste", FeedbackFieldKind.Choice, false,
Options: new[] { new FeedbackOption("a", "Seul") })))),
("étoiles", With(FeedbackKinds.Review, f => f with { Fields = f.Fields.Select(x => x.Id == "rating" ? x with { RatingMax = 4 } : x).ToList() })),
("aperçu", With(FeedbackKinds.Bug, f => f with { Fields = f.Fields.Select(x => x with { ShowInList = x.Kind == FeedbackFieldKind.Choice }).ToList() })),
("caractères", With(FeedbackKinds.Bug, f => f with { Label = "Deux espaces" })),
("caractères", With(FeedbackKinds.Bug, f => f with { Label = "Invisible​ici" })),
("icône", With(FeedbackKinds.Bug, f => f with { IconKey = "Delete" })),
("longueur", With(FeedbackKinds.Bug, f => AddField(f, new FeedbackField("x", "Court", FeedbackFieldKind.Text, false, MaxLength: 500)))),
("champs au plus", With(FeedbackKinds.Bug, f => f with { Fields = Enumerable.Range(0, 16)
.Select(i => new FeedbackField($"f{i}", $"Champ {i}", FeedbackFieldKind.YesNo, false)).ToList() })),
("rester proposé", FeedbackFormsKit.Custom().Select(f => f with { Hidden = true }).ToList()),
("1 à 8", Enumerable.Range(1, 9).Select(i => FeedbackFormsKit.NewKind($"type_{i}")).ToList()),
("en double", FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind(FeedbackKinds.Bug)).ToList()),
("identifiant invalide", FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind("Type 9")).ToList()),
("64 × 64", With(FeedbackKinds.Bug, f => f with { CustomIcon = Convert.ToBase64String(FeedbackTestImages.PngWith("tEXt", "x")) })),
("illisible", With(FeedbackKinds.Bug, f => f with { CustomIcon = "pas du base64 !" }))
};
foreach (var (expected, forms) in cases)
{
var errors = new List<string>();
Assert.False(FeedbackFormRules.Validate(FeedbackFormsKit.Document(2, forms), errors), expected);
Assert.Contains(errors, e => e.Contains(expected, StringComparison.OrdinalIgnoreCase));
}
var revisionErrors = new List<string>();
Assert.False(FeedbackFormRules.Validate(FeedbackFormsKit.Document(0), revisionErrors));
}
[Fact]
public void New_field_kinds_are_checked_like_on_the_server()
{
var bug = FeedbackFormsKit.Custom().Single(f => f.Kind == FeedbackKinds.Bug);
var review = FeedbackFormsKit.Custom().Single(f => f.Kind == FeedbackKinds.Review);
string? Check(FeedbackField field, string? value, out List<string> errors)
{
errors = new List<string>();
return FeedbackRules.CheckField(field, value, errors);
}
var platform = bug.Field("platform")!;
Assert.Equal("solo,menu", Check(platform, "menu,solo", out var e1)); // ordre du formulaire
Assert.Empty(e1);
Assert.Null(Check(platform, "solo,pirate", out var e2));
Assert.NotEmpty(e2);
Assert.Null(Check(platform, "solo,solo", out var e3));
Assert.NotEmpty(e3);
Assert.Null(Check(platform, null, out var e4));
Assert.Contains("cochez", e4[0]);
Assert.Equal("Partie solo, Menus", platform.DisplayValue("solo,menu"));
var again = bug.Field("again")!;
Assert.Equal(FeedbackRules.No, Check(again, null, out _));
Assert.Null(Check(again, "peut-être", out var e5));
Assert.NotEmpty(e5);
Check(again with { Required = true }, FeedbackRules.No, out var e6);
Assert.Contains("obligatoire", e6[0]);
Assert.Equal("Oui", again.DisplayValue(FeedbackRules.Yes));
var version = bug.Field("version_seen")!;
Assert.Equal("0.9.3 bêta", Check(version, " 0.9.3 bêta ", out _));
Assert.Null(Check(version, new string('x', 41), out var e7));
Assert.NotEmpty(e7);
var rating = review.Field("rating")!;
Assert.Equal("10", Check(rating, "10", out _));
Assert.Null(Check(rating, "11", out _));
Assert.Null(Check(rating, "0", out _));
Assert.Null(Check(rating, "07", out _));
Assert.Equal("8/10", rating.DisplayValue("8"));
Assert.Equal("8/10", review.Badge(new Dictionary<string, string> { ["rating"] = "8" }));
}
[Fact]
public void Icons_are_imported_only_from_an_ico_file_with_a_64_pixel_image()
{
// Bitmap 32 bits : converti en PNG 64 × 64 neuf, accepté tel quel dans des formulaires signés.
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
Assert.Null(FeedbackImages.Sanitize(png!, out var info, out var clean));
Assert.Equal((FeedbackImages.Png, 64, 64), (info!.Format, info.Width, info.Height));
Assert.Equal(clean, png);
Assert.Null(FeedbackIcons.CheckCustom(Convert.ToBase64String(png!)));
// Bitmap 24 bits (transparence du masque), image PNG intégrée, plusieurs tailles dont 64 × 64.
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(bits: 24), out _));
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((64, png!)), out var fromPng));
Assert.Equal(png, fromPng);
var sixteen = FeedbackFormsKit.Ico(16);
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((16, sixteen[22..]), (64, png!)), out _));
// Refus : pas d'image 64 × 64, 256 couleurs, autre format, fichier abîmé, PNG intégré trompeur.
Assert.Contains("32 × 32", FeedbackIcons.FromIco(FeedbackFormsKit.Ico(32), out _));
Assert.Contains("256 couleurs", FeedbackIcons.FromIco(FeedbackFormsKit.Ico(bits: 8), out _));
Assert.Contains("n'est pas une icône", FeedbackIcons.FromIco(png, out _));
Assert.Contains("n'est pas une icône", FeedbackIcons.FromIco(System.Text.Encoding.UTF8.GetBytes("MZ\0\0 programme"), out _));
var truncated = FeedbackFormsKit.Ico();
Assert.Contains("endommagée", FeedbackIcons.FromIco(truncated.AsSpan(0, 400), out _));
Assert.NotNull(FeedbackIcons.FromIco(FeedbackFormsKit.IcoOf((64, FeedbackTestImages.PngWith("tEXt", "x"))), out _)); // 64 × 48
// Formulaires : type ajouté avec une icône importée, captures refusées pour ce type.
var forms = FeedbackFormsKit.Custom().Append(FeedbackFormsKit.NewKind("type_1", attachments: false, icon: Convert.ToBase64String(png!))).ToList();
var errors = new List<string>();
Assert.True(FeedbackFormRules.Validate(FeedbackFormsKit.Document(2, forms), errors), string.Join(" ", errors));
var submission = new FeedbackSubmission
{
GameId = "EZR", Kind = "type_1", Title = "Un niveau dans le gymnase", AttachmentCount = 1,
Fields = new() { ["message"] = "Avec des zombies basketteurs." }
};
errors.Clear();
Assert.Null(FeedbackRules.Validate(submission, forms[^1], errors));
Assert.Contains(errors, e => e.Contains("captures d'écran ne sont pas acceptées"));
errors.Clear();
Assert.NotNull(FeedbackRules.Validate(submission with { AttachmentCount = 0 }, forms[^1], errors));
}
// ------------------------------------------------------------------ Lecture par le launcher des joueurs
private static readonly Uri KeyringUrl = new("https://launcher.example/catalog/keyring.json");
private static readonly Uri FormsUrl = FeedbackFormsService.PublishedUrl(KeyringUrl.AbsoluteUri, null)!;
/// <summary>Serveur simulé : fichiers servis par adresse (absent : 404).</summary>
private sealed class FakeFiles
{
public Dictionary<Uri, byte[]> Files { get; } = new();
public bool Offline { get; set; }
public Task<byte[]> Download(Uri uri, long max, CancellationToken ct)
{
if (Offline) throw new HttpRequestException("hors ligne");
if (!Files.TryGetValue(uri, out var bytes))
throw new HttpRequestException("introuvable", null, System.Net.HttpStatusCode.NotFound);
if (bytes.Length > max) throw new InvalidDataException("trop grand");
return Task.FromResult(bytes);
}
}
[Fact]
public async Task Players_use_published_forms_only_when_signed_by_a_key_allowed_for_forms()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var designer = SigningKit.NewKey();
using var editor = SigningKit.NewKey();
var files = new FakeFiles();
files.Files[KeyringUrl] = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(designer, "Nicolas", SigningScopes.Forms), SigningKit.Entry(editor, "Paul", SigningScopes.Catalog))));
ContentTrustService Trust() =>
new(files.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) }, ContentSigningMode.Require);
FeedbackFormsService Service() => new(files.Download, FormsUrl, folder.Path, Trust());
Assert.Equal(new Uri("https://launcher.example/catalog/feedback-forms.json"), FormsUrl);
// Rien de publié : formulaires d'origine.
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
// Signés par une clé sans l'autorisation « forms », ou non signés : refusés.
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(editor, 1));
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Document(1));
Assert.Equal(0, (await Service().LoadAsync(false, default)).Revision);
// Signés par une clé autorisée : utilisés, et gardés pour une prochaine ouverture hors ligne.
var now = DateTimeOffset.UtcNow;
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 2, at: now));
var forms = await Service().LoadAsync(false, default);
Assert.Equal(2, forms.Revision);
Assert.Equal(FeedbackFormsKit.NewBugLabel, forms.Find(FeedbackKinds.Bug)!.Label);
Assert.DoesNotContain(forms.Visible, f => f.Kind == FeedbackKinds.Other);
files.Offline = true;
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
files.Offline = false;
// Retour en arrière (révision ou signature plus anciennes) : refusé, la copie gardée reste utilisée.
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 1, at: now.AddMinutes(5)));
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 3, at: now.AddHours(-1)));
Assert.Equal(2, (await Service().LoadAsync(false, default)).Revision);
// Copie gardée modifiée sur le disque : ignorée (signature revérifiée).
var service = Service();
File.WriteAllText(service.CachePath, FeedbackFormRules.Serialize(FeedbackFormsKit.Signed(designer, 2, at: now) with { Revision = 9 }));
files.Offline = true;
Assert.Equal(0, (await service.LoadAsync(false, default)).Revision);
// Fichier hors limites, même signé (lien dans un libellé) : refusé.
files.Offline = false;
var linked = FeedbackFormsKit.Custom("Voir site.ru");
files.Files[FormsUrl] = FeedbackFormsKit.Bytes(FeedbackFormsKit.Signed(designer, 4, linked, now.AddMinutes(10)));
Assert.NotEqual(4, (await Service().LoadAsync(false, default)).Revision);
}
// ------------------------------------------------------------------ Fenêtre « Aide »
private sealed class FakeForms : IFeedbackFormsSource
{
public FeedbackFormsDocument Current { get; set; } = FeedbackFormsKit.Document(1);
public int Refreshes { get; private set; }
public Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct)
{
if (refresh) Refreshes++;
return Task.FromResult(Current);
}
}
private sealed class RecordingSender : IFeedbackSender
{
public List<FeedbackSubmission> Sent { get; } = new();
public Exception? NextFailure { get; set; }
public Task<FeedbackSent> SendAsync(FeedbackSubmission submission, IReadOnlyList<byte[]> attachments, CancellationToken ct)
{
if (NextFailure is { } failure)
{
NextFailure = null;
throw failure;
}
Sent.Add(submission);
return Task.FromResult(new FeedbackSent("FB-7K2M-Q9XD", attachments.Count, null));
}
}
[Fact]
public async Task The_help_window_builds_published_forms_and_reloads_them_when_republished_during_input()
{
var forms = new FakeForms();
var sender = new RecordingSender();
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival", "0.9.3", "3.2.0"), sender, forms: forms);
await form.FormsLoad;
Assert.Equal(1, form.FormsRevision);
Assert.Equal(3, form.Kinds.Count); // « autre » masqué
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Review).SelectCommand.Execute(null);
Assert.Equal(10, form.Fields.Single(f => f.Id == "rating").Stars.Count);
form.BackCommand.Execute(null);
form.Kinds.Single(k => k.Form.Kind == FeedbackKinds.Bug).SelectCommand.Execute(null);
Assert.Equal(FeedbackFormsKit.NewBugLabel, form.FormTitle);
FeedbackFieldViewModel Field(string id) => form.Fields.Single(f => f.Id == id);
form.Title = "Le jeu se fige";
Field("category").SelectedOption = Field("category").Options.Single(o => o.Id == "gameplay");
Field("severity").SelectedOption = Field("severity").Options.Single(o => o.Id == "blocking");
Field("frequency").SelectedOption = Field("frequency").Options.Single(o => o.Id == "often");
Field("steps").Text = "Ouvrir l'inventaire.";
Field("actual").Text = "L'image se fige.";
Field("version_seen").Text = "0.9.3";
Field("again").IsChecked = true;
Assert.True(Field("version_seen").IsText && Field("again").IsYesNo && Field("platform").IsCheckboxes);
// Case obligatoire non cochée : refus local, rien n'est envoyé.
await form.SendCommand.ExecuteAsync();
Assert.Empty(sender.Sent);
Assert.True(Field("platform").HasError);
Field("platform").CheckOptions.Single(o => o.Option.Id == "coop").IsChecked = true;
Field("platform").CheckOptions.Single(o => o.Option.Id == "solo").IsChecked = true;
// Republiés pendant la saisie (409) : relus, saisie reprise pour les champs restés identiques.
var republished = FeedbackFormsKit.Custom("Bogue").Select(f => f.Kind != FeedbackKinds.Bug ? f : f with
{
Fields = f.Fields.Where(x => x.Id != "version_seen").ToList()
}).ToList();
forms.Current = FeedbackFormsKit.Document(2, republished);
sender.NextFailure = new ApiException(409, ApiErrorCodes.FeedbackFormChanged, "Le formulaire vient d'être mis à jour.");
await form.SendCommand.ExecuteAsync();
Assert.Equal(1, forms.Refreshes);
Assert.Contains("mis à jour", form.ErrorMessage);
Assert.Equal("Bogue", form.FormTitle);
Assert.Equal(2, form.FormsRevision);
Assert.DoesNotContain(form.Fields, f => f.Id == "version_seen");
Assert.Equal("L'image se fige.", Field("actual").Text);
Assert.True(Field("again").IsChecked);
Assert.Equal("blocking", Field("severity").SelectedOption!.Id);
await form.SendCommand.ExecuteAsync();
Assert.True(form.IsDone, form.ErrorMessage);
var sent = Assert.Single(sender.Sent);
Assert.Equal(2, sent.FormRevision);
Assert.Equal("solo,coop", sent.Fields["platform"]);
Assert.Equal(FeedbackRules.Yes, sent.Fields["again"]);
Assert.False(sent.Fields.ContainsKey("version_seen"));
}
private sealed class NoScreenshots : IScreenshotSource
{
public ScreenshotResult? PickFile() => null;
public ScreenshotResult? FromClipboard() => null;
}
[Fact]
public async Task The_help_window_shows_imported_icons_and_hides_screenshots_when_not_allowed()
{
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
var forms = new FakeForms
{
Current = FeedbackFormsKit.Document(3, new List<FeedbackForm>
{
FeedbackFormsKit.NewKind("type_1", attachments: false, icon: Convert.ToBase64String(png!)),
FeedbackForms.All[2]
})
};
using var form = new FeedbackFormViewModel(new FeedbackTarget("EZR", "Exovival", "0.9.3", "3.2.0"), new RecordingSender(),
screenshots: new NoScreenshots(), forms: forms);
await form.FormsLoad;
Assert.Equal(new[] { "type_1", FeedbackKinds.Bug }, form.Kinds.Select(k => k.Form.Kind).ToArray()); // ordre publié
Assert.True(form.Kinds[0].HasCustomIcon);
Assert.False(form.Kinds[1].HasCustomIcon);
form.Kinds[0].SelectCommand.Execute(null);
Assert.True(form.HasFormCustomIcon);
Assert.False(form.AttachmentsAllowed);
Assert.False(form.AddFileCommand.CanExecute(null));
form.BackCommand.Execute(null);
form.Kinds[1].SelectCommand.Execute(null);
Assert.True(form.AttachmentsAllowed);
Assert.False(form.HasFormCustomIcon);
}
}
@@ -0,0 +1,196 @@
using System.Net;
using GameLauncher.ApiClient;
using GameLauncher.Contracts;
using GameLauncher.Contracts.Dtos;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Contracts.Security;
using GameLauncher.Contracts.Signing;
using GameLauncher.Server.Storage;
using GameLauncher.Tests.Contracts;
using GameLauncher.Tests.Infrastructure;
using GameLauncher.Tests.Signing;
using Microsoft.Extensions.DependencyInjection;
namespace GameLauncher.Tests.Server;
/// <summary>Formulaires des demandes : publication signée depuis le menu, fichier public, validation des demandes par révision.</summary>
public class FeedbackFormsModuleTests
{
private const string Game = "EZR";
private static async Task<(TestApi Api, string PublicDirectory)> StartAsync()
{
var directory = Path.Combine(Path.GetTempPath(), "launcher-forms-tests", Guid.NewGuid().ToString("N"), "catalog");
Directory.CreateDirectory(directory);
var api = await TestApi.StartAsync(new Dictionary<string, string?>
{
["LauncherApi:Catalog:PublicFile"] = Path.Combine(directory, "catalog.json"),
["LauncherApi:Feedback:Enabled"] = "true",
["LauncherApi:Feedback:Games:0"] = Game,
["LauncherApi:Feedback:ProofDifficulty"] = "8",
["LauncherApi:Feedback:RequestsPerMinute"] = "10000",
["LauncherApi:Feedback:PerIpPer10Minutes"] = "1000",
["LauncherApi:Feedback:PerIpPerDay"] = "10000"
});
return (api, directory);
}
private static FeedbackSubmission CustomBug(long revision) => FeedbackModuleTests.Bug() with
{
FormRevision = revision,
Fields = new Dictionary<string, string>
{
["category"] = "crash", ["severity"] = "blocking", ["frequency"] = "always", ["steps"] = "Ouvrir l'inventaire.",
["actual"] = "Le jeu se ferme.", ["platform"] = "coop,solo", ["version_seen"] = "0.9.3", ["again"] = FeedbackRules.Yes
}
};
[Fact]
public async Task Published_forms_are_signed_public_and_used_to_check_and_display_requests()
{
var (api, directory) = await StartAsync();
await using var _ = api;
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
var state = await owner.GetFeedbackFormsAsync();
Assert.Equal(0, state.Document.Revision);
Assert.True(state.PublishingEnabled);
Assert.EndsWith("/catalog/feedback-forms.json", state.PublicUrl);
state = await owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
Assert.Equal(1, state.Document.Revision);
Assert.Equal(key.KeyId, state.Document.Signature!.KeyId);
Assert.NotNull(state.UpdatedBy);
// Fichier public à côté du trousseau, signature valable pour les launchers (condensé recalculé sur le fichier).
var published = FeedbackFormRules.Parse(File.ReadAllBytes(Path.Combine(directory, "feedback-forms.json")))!;
Assert.True(ContentSigning.VerifyWithKey(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(published),
published.Signature!, key.PublicKey));
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("Problème technique", audit.Details);
Assert.Contains("+3 champ(s)", audit.Details);
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
using var client = api.CreateClient();
await client.SendFeedbackAsync(CustomBug(1));
// Ancien launcher (formulaires d'origine, révision 0) : toujours accepté…
await client.SendFeedbackAsync(FeedbackModuleTests.Bug("Ancien launcher"));
// … sauf pour un type masqué depuis.
var hidden = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(FeedbackModuleTests.Bug("Autre chose") with
{
Kind = FeedbackKinds.Other, Fields = new() { ["message"] = "Bonjour" }, Tech = null
}));
Assert.Contains("n'est plus proposé", hidden.Message);
// Révision dépassée : 409 (le launcher relit les formulaires).
var stale = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(CustomBug(7)));
Assert.Equal(409, stale.Status);
Assert.Equal(ApiErrorCodes.FeedbackFormChanged, stale.Code);
// Champ inconnu de la révision annoncée : refusé.
await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(CustomBug(0)));
var items = (await owner.GetFeedbackPageAsync(Game)).Items;
var custom = await owner.GetFeedbackAsync(Game, items.Single(i => i.Title != "Ancien launcher").Id);
Assert.Equal(1, custom.FormRevision);
Assert.Equal("Problème technique", custom.Form!.Label);
Assert.Equal("solo,coop", custom.Fields["platform"]);
Assert.Equal("Bloquant", custom.Summary.Badge);
var legacy = await owner.GetFeedbackAsync(Game, items.Single(i => i.Title == "Ancien launcher").Id);
Assert.Equal(0, legacy.FormRevision);
Assert.Null(legacy.Form);
Assert.Equal("Plantage", legacy.Summary.Badge);
}
[Fact]
public async Task Publication_needs_the_permission_the_session_key_and_the_current_revision()
{
var (api, _) = await StartAsync();
await using var __ = api;
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
// Éditeur (sans feedback.forms) : refusé.
var (editorKey, _) = await api.AddAdminAsync(owner, "Léo", AdminRoles.Editor);
await using (var editor = await api.LoginAsync(editorKey))
{
var denied = await Assert.ThrowsAsync<ApiException>(() => editor.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0));
Assert.Equal(403, denied.Status);
Assert.Equal(0, (await editor.GetFeedbackFormsAsync()).Document.Revision); // lecture : contenu public
}
editorKey.Dispose();
await owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom(), 0);
// Publication fondée sur une révision dépassée : 409, rien n'est écrasé.
var conflict = await Assert.ThrowsAsync<ApiException>(() => owner.PublishFeedbackFormsAsync(FeedbackFormsKit.Custom("Bogue"), 0));
Assert.Equal(409, conflict.Status);
// Formulaires signés par une autre clé que celle de la session, ou non signés : refusés.
var login = await api.RawLoginAsync(key);
using var stranger = SigningKit.NewKey();
foreach (var document in new[] { FeedbackFormsKit.Signed(stranger, 2), FeedbackFormsKit.Document(2) })
{
using var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.FeedbackForms,
new FeedbackFormsPublishRequest { BaseRevision = 1, Document = document });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
// Formulaires hors règles (lien), même bien signés : refusés par le serveur.
var linked = FeedbackFormsKit.Document(2, FeedbackFormsKit.Custom("Voir www.site.com"));
var signature = await owner.SignContentAsync(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, FeedbackFormRules.Digest(linked));
using (var response = await api.SendSignedRawAsync(login, key, HttpMethod.Put, ApiRoutes.FeedbackForms,
new FeedbackFormsPublishRequest { BaseRevision = 1, Document = linked with { Signature = signature } }))
{
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("liens", (await TestApi.ReadErrorAsync(response)).Message);
}
Assert.Equal(1, (await owner.GetFeedbackFormsAsync()).Document.Revision);
}
[Fact]
public async Task Categories_can_be_added_removed_reordered_with_imported_icons_and_without_screenshots()
{
var (api, directory) = await StartAsync();
await using var _ = api;
using var key = api.AddOwner();
await using var owner = await api.LoginAsync(key);
// Huit types (le maximum), chacun avec une icône importée : publication au-delà de 64 Ko acceptée par cette route.
Assert.Null(FeedbackIcons.FromIco(FeedbackFormsKit.Ico(), out var png));
var icon = Convert.ToBase64String(png!);
var forms = new List<FeedbackForm> { FeedbackFormsKit.NewKind("type_1", "Suggestion de niveau", attachments: false, icon: icon) }
.Concat(FeedbackForms.All.Where(f => f.Kind != FeedbackKinds.Other).Select(f => f with { CustomIcon = icon }))
.Concat(Enumerable.Range(2, 4).Select(i => FeedbackFormsKit.NewKind($"type_{i}", $"Type {i}", icon: icon)))
.ToList();
Assert.Equal(FeedbackFormRules.MaxForms, forms.Count);
var state = await owner.PublishFeedbackFormsAsync(forms, 0);
Assert.Equal("type_1", state.Document.Forms[0].Kind); // ordre conservé
Assert.Equal(icon, state.Document.Forms[0].CustomIcon);
Assert.True(new FileInfo(Path.Combine(directory, "feedback-forms.json")).Length > 64 * 1024);
var audit = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries.Single(e => e.Action == "feedback.forms");
Assert.Contains("type(s) ajouté(s)", audit.Details);
Assert.Contains("« Autre »", audit.Details);
using var client = api.CreateClient();
// Nouveau type : accepté, mais sans capture d'écran.
var suggestion = new FeedbackSubmission
{
GameId = Game, Kind = "type_1", FormRevision = 1, Title = "Un niveau dans le gymnase",
Fields = new() { ["message"] = "Avec des zombies basketteurs." }
};
var refused = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(suggestion, new[] { FeedbackTestImages.PngWith("tEXt", "x") }));
Assert.Contains("captures d'écran ne sont pas acceptées", refused.Message);
await client.SendFeedbackAsync(suggestion);
// Type d'origine supprimé : refusé aussi aux anciens launchers (formulaires d'origine).
var removed = await Assert.ThrowsAsync<ApiException>(() => client.SendFeedbackAsync(FeedbackModuleTests.Bug("Autre chose") with
{
Kind = FeedbackKinds.Other, Fields = new() { ["message"] = "Bonjour" }, Tech = null
}));
Assert.Contains("n'est plus proposé", removed.Message);
// Liste du menu : filtre par le nouveau type, nom et icône conservés avec la demande.
var page = await owner.GetFeedbackPageAsync(Game, "type_1", null);
var detail = await owner.GetFeedbackAsync(Game, Assert.Single(page.Items).Id);
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
Assert.Equal(icon, detail.Form.CustomIcon);
}
}
@@ -166,15 +166,22 @@ public class ContentSigningTests
Assert.Contains("inconnue", error);
Assert.False(Keyring.VerifyRoot(keyring with { Signature = null }, roots, out _));
// Structure : identifiant ne correspondant pas à la clé, autorisation inconnue.
// Structure : identifiant ne correspondant pas à la clé, autorisation mal formée.
var errors = new List<string>();
Assert.False(Keyring.Validate(keyring with
{
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "admin") }
Keys = new() { SigningKit.Entry(admin, "Nicolas") with { KeyId = SigningKit.KeyId(intruder) }, SigningKit.Entry(intruder, "X", "Admin !") }
}, errors));
Assert.Contains(errors, e => e.Contains("ne correspond pas"));
Assert.Contains(errors, e => e.Contains("autorisations"));
// Autorisation inconnue mais bien formée (ajoutée par une version plus récente) : trousseau accepté, elle n'autorise rien ici.
errors.Clear();
var future = keyring with { Keys = new() { SigningKit.Entry(admin, "Nicolas", SigningScopes.Catalog, "news") } };
Assert.True(Keyring.Validate(future, errors), string.Join(" ", errors));
Assert.False(ContentSigning.Verify(ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, "x",
SigningKit.Sign(admin, ContentKinds.FeedbackForms, ContentKinds.FeedbackFormsId, "x"), future).Valid);
// Membres absents (« keys »: null, « scopes »: null…) : refusés sans exception.
foreach (var json in new[]
{
+19
View File
@@ -169,6 +169,10 @@ internal sealed class FakeApi : HttpMessageHandler
// ---- Demandes des joueurs (lecture, traitement accepté sans rien enregistrer, captures d'écran)
case ["v1", "feedback", "overview"] when get:
return Json(_world.FeedbackCounts());
case ["v1", "feedback", "forms"] when get:
return Json(_world.FormsState());
case ["v1", "feedback", "forms"] when method == HttpMethod.Put:
return Json(_world.FormsPublished(Read<FeedbackFormsPublishRequest>(body)));
case ["v1", "games", var gameId, "feedback"] when get:
return Json(_world.FeedbackList(gameId, QueryValue(query, "kind"), QueryValue(query, "status")));
case ["v1", "games", var gameId, "feedback", var feedbackId] when get:
@@ -720,6 +724,21 @@ internal sealed class SampleWorld
// ------------------------------------------------------------------ Demandes des joueurs
/// <summary>Formulaires publiés de démonstration (révision 3, signés par le propriétaire).</summary>
public FeedbackFormsState FormsState() => new()
{
Document = SampleData.SampleFeedbackForms(),
PublishingEnabled = true,
PublicUrl = "https://launcher.example/catalog/feedback-forms.json",
UpdatedBy = OwnerName
};
/// <summary>Publication acceptée sans rien enregistrer : révision suivante renvoyée.</summary>
public FeedbackFormsState FormsPublished(FeedbackFormsPublishRequest request) => FormsState() with
{
Document = request.Document with { UpdatedAt = DateTimeOffset.Now }
};
public FeedbackOverview FeedbackCounts() => new()
{
Enabled = true,
+70
View File
@@ -8,6 +8,7 @@ using System.Windows.Media;
using System.Windows.Media.Imaging;
using GameLauncher.Contracts.Catalog;
using GameLauncher.Contracts.Changelog;
using GameLauncher.Contracts.Feedback;
using GameLauncher.Core.Models;
using GameLauncher.Core.SelfUpdate;
@@ -257,6 +258,75 @@ internal static class SampleData
public static string Hex(byte[] bytes) => Convert.ToHexString(bytes).ToLowerInvariant();
// ------------------------------------------------------------------ Formulaires des demandes
/// <summary>
/// Formulaires publiés de démonstration (révision 3) : bug renommé, avec cases à cocher, note sur 10, texte court et
/// case oui/non ; « autre » masqué. Fenêtre « Aide » (feedback-custom) et page « Formulaires des demandes » du menu.
/// </summary>
public static FeedbackFormsDocument SampleFeedbackForms() => new()
{
Revision = 3,
UpdatedAt = DateTimeOffset.Now.AddDays(-2),
// Type ajouté depuis le menu, en premier : icône importée (.ico 64 × 64), sans captures d'écran.
Forms = new[]
{
new FeedbackForm("type_1", "Suggestion de niveau", "Proposez un lieu ou une mission pour la suite.", "Lightbulb",
"Ex. : un niveau dans le gymnase", TechByDefault: false,
new[]
{
new FeedbackField("message", "Votre idée", FeedbackFieldKind.Multiline, Required: true, "Le lieu, l'ambiance, les objectifs…"),
new FeedbackField("mode", "Pour quel mode ?", FeedbackFieldKind.Choice, Required: false, Options: new[]
{
new FeedbackOption("solo", "Solo"), new FeedbackOption("coop", "Coopératif")
})
},
AllowAttachments: false, CustomIcon: Convert.ToBase64String(SampleIcon()))
}.Concat(FeedbackForms.All.Select(form => form.Kind switch
{
FeedbackKinds.Bug => form with
{
Label = "Problème technique",
Fields = form.Fields.Where(f => f.Id is "category" or "severity" or "steps")
.Append(new FeedbackField("platform", "Où cela arrive-t-il ?", FeedbackFieldKind.Checkboxes, Required: true,
"Plusieurs réponses possibles.", Options: new[]
{
new FeedbackOption("solo", "Partie solo"), new FeedbackOption("coop", "Coopératif"),
new FeedbackOption("menu", "Menus et options")
}))
.Append(new FeedbackField("rating10", "Gêne pour jouer", FeedbackFieldKind.Rating, Required: false,
"1 : à peine visible, 10 : injouable.", RatingMax: 10))
.Append(new FeedbackField("version_seen", "Version affichée en jeu", FeedbackFieldKind.Text, Required: false,
"En bas à droite de l'écran titre", MaxLength: 40))
.Append(new FeedbackField("again", "Le problème revient après un redémarrage du jeu", FeedbackFieldKind.YesNo,
Required: false, "Cochez si le problème réapparaît."))
.ToList()
},
FeedbackKinds.Other => form with { Hidden = true },
_ => form
})).ToList()
};
/// <summary>Icône « importée » de démonstration : PNG 64 × 64 sans métadonnées (comme après l'import d'un .ico).</summary>
public static byte[] SampleIcon()
{
var visual = new DrawingVisual();
using (var dc = visual.RenderOpen())
{
var background = new LinearGradientBrush(Color.FromRgb(0x2E, 0x7D, 0x32), Color.FromRgb(0x8B, 0xC3, 0x4A), 45);
dc.DrawRoundedRectangle(background, null, new Rect(2, 2, 60, 60), 14, 14);
dc.DrawEllipse(Brushes.White, null, new Point(32, 26), 12, 12);
dc.DrawRectangle(Brushes.White, null, new Rect(26, 38, 12, 14));
}
var bitmap = new RenderTargetBitmap(FeedbackIcons.Size, FeedbackIcons.Size, 96, 96, PixelFormats.Pbgra32);
bitmap.Render(visual);
var encoder = new PngBitmapEncoder { Interlace = PngInterlaceOption.Off };
encoder.Frames.Add(BitmapFrame.Create(bitmap));
using var stream = new MemoryStream();
encoder.Save(stream);
return FeedbackImages.Sanitize(stream.ToArray(), out _, out var clean) is null ? clean! : stream.ToArray();
}
// ------------------------------------------------------------------ Captures d'écran (demandes des joueurs)
/// <summary>
+9 -3
View File
@@ -39,13 +39,19 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
bug rempli avec une capture d'écran (formulaire qui défile : pages `.p2.png`…), champs obligatoires
signalés, demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
signalés, types publiés depuis le menu (`feedback-custom-kinds` : ordre, type ajouté avec une icône
importée, type masqué), formulaire publié avec tous les types de champs (`feedback-custom` : cases à
cocher, note sur 10, texte court, case oui/non ; type « autre » masqué), demande envoyée (avec l'avertissement d'une capture refusée). Envoi simulé, capture d'écran de
démonstration (ni fichier choisi, ni presse-papiers).
* **Menu d'administration** (`admin-*`) : connexion (normale, refusée, verrouillée), « J'ai une
invitation » (formulaire, empreinte), puis **chaque page déclarée par les modules** (`admin-page-<id>`,
lues dans `App.CreateModules` : une page ajoutée plus tard est capturée sans modifier l'outil), une
version prête à publier, et la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
lien signalé, captures d'écran).
version prête à publier, la fiche d'une demande de joueur (`admin-page-feedback-requests-detail` :
lien signalé, captures d'écran) et un formulaire des demandes en cours de modification
(`admin-page-feedback-forms-edit` : champ ajouté et déplié, erreur « lien » et avertissement « donnée
personnelle », aperçu tel que les joueurs le verront), puis un type de demande ajouté
(`admin-page-feedback-forms-new-type` : icône importée, captures d'écran désactivées, déplacé) ; rien
n'est publié.
`--theme all` rend chaque scène dans chaque thème intégré (suffixe `@default`, `@exovival`).
+44
View File
@@ -46,6 +46,8 @@ internal sealed class AdminScenes : ISceneSet
(PagePrefix + "changelog-notes-game", "Administration · notes de version (jeu choisi)"),
(PagePrefix + "releases-publish-ready", "Administration · version prête à publier"),
(PagePrefix + "feedback-requests-detail", "Administration · demande d'un joueur (lien signalé, captures d'écran)"),
(PagePrefix + "feedback-forms-edit", "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)"),
(PagePrefix + "feedback-forms-new-type", "Administration · type de demande ajouté (icône importée, sans captures d'écran)"),
("admin-login-locked", "Administration · session verrouillée")
};
@@ -239,6 +241,48 @@ internal sealed class AdminScenes : ISceneSet
await ShootAllThemesAsync(feedbackId, "Administration · demande d'un joueur (lien signalé, captures d'écran)");
}
// ---- Formulaires des demandes : type « bug » modifié (champ ajouté et déplié), erreur (lien) et avertissement
// (donnée personnelle) affichés, aperçu à jour. Rien n'est publié.
var formsId = PagePrefix + "feedback-forms-edit";
if (capture.WantsAny(capture.Themes.Select(t => formsId + Capture.Suffix(t))) &&
workspace.PanelItems.FirstOrDefault(p => p.Descriptor.Id == FeedbackFormsPanelViewModel.PanelId) is { } formsItem)
{
workspace.SelectedPanel = formsItem;
await WaitForPanelAsync(capture, formsItem, api);
if (formsItem.ViewModel is FeedbackFormsPanelViewModel forms &&
forms.Forms.FirstOrDefault(f => f.Kind == GameLauncher.Contracts.Feedback.FeedbackKinds.Bug) is { } bug)
{
forms.SelectedForm = bug;
bug.Description = "Plus d'informations sur www.exemple.com";
bug.FieldKinds.First(k => k.Kind == GameLauncher.Contracts.Feedback.FeedbackFieldKind.Checkboxes).AddCommand.Execute(null);
var added = bug.Fields.Last();
added.Label = "Sur quel matériel ?";
added.Hint = "Indiquez aussi votre adresse e-mail si besoin";
added.Options[0].Label = "Manette";
added.Options[1].Label = "Clavier et souris";
}
else
{
capture.GlobalNotes.Add("⚠ Formulaires des demandes : page ou type « bug » introuvable.");
}
await ShootAllThemesAsync(formsId, "Administration · formulaire en cours de modification (champ ajouté, contrôles, aperçu)");
// Type ajouté : icône importée (comme depuis un .ico 64 × 64), captures d'écran désactivées, placé en deuxième.
var newTypeId = PagePrefix + "feedback-forms-new-type";
if (capture.WantsAny(capture.Themes.Select(t => newTypeId + Capture.Suffix(t))) && formsItem.ViewModel is FeedbackFormsPanelViewModel page &&
page.AddFormCommand.CanExecute(null))
{
page.AddFormCommand.Execute(null);
var newType = page.SelectedForm!;
newType.Label = "Problème de compte";
newType.Description = "Connexion, sauvegarde en ligne, succès.";
newType.SetCustomIcon(Convert.ToBase64String(SampleData.SampleIcon()));
newType.AllowAttachments = false;
while (page.Forms.IndexOf(newType) > 1) newType.MoveUpCommand.Execute(null);
await ShootAllThemesAsync(newTypeId, "Administration · type de demande ajouté (icône importée, sans captures d'écran)");
}
}
// ---- Verrouillage
await shell.LockAsync("Session verrouillée après 10 minutes sans activité.");
await ShootAllThemesAsync("admin-login-locked", "Administration · session verrouillée");
+28 -4
View File
@@ -8,7 +8,8 @@ namespace UiSnapshot.Scenes;
/// <summary>
/// Fenêtre « Aide » du joueur (bouton de la barre de titre) : choix du type, formulaires (avis noté, bug rempli
/// avec une capture d'écran, champs obligatoires signalés), demande envoyée. Vrai ViewModel et vraie fenêtre ;
/// avec une capture d'écran, champs obligatoires signalés, formulaire publié depuis le menu avec tous les types de
/// champs), demande envoyée. Vrai ViewModel et vraie fenêtre ;
/// l'envoi est simulé (aucun réseau) et la capture d'écran est une image de démonstration (ni fichier choisi,
/// ni presse-papiers). Un formulaire plus haut que la fenêtre donne des pages supplémentaires (.p2.png…).
/// </summary>
@@ -46,6 +47,18 @@ internal sealed class FeedbackScenes : ISceneSet
form.Title = "Bug";
await form.SendCommand.ExecuteAsync(); // contrôle local : rien n'est envoyé
}),
("feedback-custom-kinds", "Aide · types publiés depuis le menu (ordre, type ajouté avec icône importée, type masqué)",
_ => Task.CompletedTask),
("feedback-custom", "Aide · formulaire publié depuis le menu (tous les types de champs)", form =>
{
Choose(form, FeedbackKinds.Bug);
form.Title = "Le son disparaît en coopératif";
Field(form, "platform").CheckOptions[1].IsChecked = true;
Field(form, "rating10").Rating = 7;
Field(form, "version_seen").Text = "0.9.3";
Field(form, "again").IsChecked = true;
return Task.CompletedTask;
}),
("feedback-done", "Aide · demande envoyée (une capture refusée par le serveur)", async form =>
{
Choose(form, FeedbackKinds.Other);
@@ -69,7 +82,8 @@ internal sealed class FeedbackScenes : ISceneSet
await capture.ShootAsync(id + Capture.Suffix(theme), title, async () =>
{
_fixture.ApplyTheme(theme);
var form = NewForm();
var form = NewForm(custom: id.StartsWith("feedback-custom", StringComparison.Ordinal));
await form.FormsLoad;
await apply(form);
if (form.HasError && id != "feedback-bug-errors") capture.Problem($"Formulaire « Aide » : {form.ErrorMessage}");
if (form.HasAttachmentError) capture.Problem($"Capture d'écran refusée : {form.AttachmentError}");
@@ -79,7 +93,8 @@ internal sealed class FeedbackScenes : ISceneSet
}
}
private FeedbackFormViewModel NewForm()
/// <param name="custom">Formulaires « publiés depuis le menu » (<see cref="SampleForms"/>) au lieu de ceux d'origine.</param>
private FeedbackFormViewModel NewForm(bool custom = false)
{
var game = _fixture.MainGame;
var target = new FeedbackTarget(game.GameId, game.ShownName, "1.4.2", _fixture.LauncherVersion);
@@ -91,7 +106,16 @@ internal sealed class FeedbackScenes : ISceneSet
Os = "Windows 11 64 bits (10.0.26100)",
Language = "fr-FR"
};
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots());
return new FeedbackFormViewModel(target, new SampleSender(), tech, new SampleScreenshots(), custom ? new SampleForms() : null);
}
/// <summary>
/// Formulaires publiés de démonstration (<see cref="SampleData.SampleFeedbackForms"/>, révision 3). Aucun réseau ni
/// signature : seul l'affichage compte ici.
/// </summary>
private sealed class SampleForms : IFeedbackFormsSource
{
public Task<FeedbackFormsDocument> LoadAsync(bool refresh, CancellationToken ct) => Task.FromResult(SampleData.SampleFeedbackForms());
}
private static void FillBug(FeedbackFormViewModel form)
+100 -91
View File
@@ -1,131 +1,140 @@
# Launcher 3.1.0
# Launcher 3.2.0
LAUNCHER - NOTES DE MISE À JOUR
Captures d'écran et liens signalés - Mise à jour du 9 novembre 2026
Formulaires des demandes modifiables - Mise à jour du 10 novembre 2026
==================================================================
Depuis le bouton « Aide », vous pouvez maintenant joindre jusqu'à 3 captures d'écran à votre demande. Chaque image est contrôlée et débarrassée de ses métadonnées (ni position GPS, ni appareil, ni date) avant l'envoi.
Les formulaires du bouton « Aide » se gèrent désormais depuis le menu d'administration : de 1 à 8 types de demande (ajout, suppression, ordre d'affichage, icône, captures d'écran autorisées ou non) et leurs champs, avec un aperçu identique à la fenêtre des joueurs.
Dans le menu d'administration, une demande contenant un lien est signalée par un avertissement, et le journal d'audit affiche des libellés lisibles. La fenêtre « Aide » reste centrée et défile avec un ascenseur. Le serveur est à redéployer, avec un bloc nginx à ajouter, avant de publier cette version.
Les formulaires sont communs à tous les jeux, signés et vérifiés par le launcher, et ne peuvent contenir aucun lien. Le serveur est à redéployer, avec deux blocs nginx à ajouter ; le trousseau est à re-signer une fois cette version diffusée.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : captures d'écran jointes à une demande (3 au plus,
fichier ou Ctrl+V) : vraie image exigée, PNG ou JPEG seulement,
sans métadonnées.
- NOUVEAU : captures affichées dans la fiche d'une demande
(administration), revérifiées avant l'affichage.
- NOUVEAU : avertissement quand une demande contient un lien ; liens
désamorcés, jamais cliquables.
- AMÉLIORÉ : journal d'audit avec des libellés lisibles (« Demande de
joueur reçue »…) et une catégorie « Demandes des joueurs ».
- AMÉLIORÉ : fenêtre « Aide » centrée, formulaire long avec un
ascenseur.
- SÉCURITÉ : image contrôlée par le launcher et par le serveur ;
envoi autorisé par un jeton propre à la demande.
- SERVEUR : à redéployer, avec un nouveau bloc nginx.
- QUALITÉ : 30 nouveaux tests automatisés (342 au total).
- NOUVEAU : page « Formulaires des demandes » dans le menu :
types de demande, champs, aperçu, contrôle en direct, publication
signée.
- NOUVEAU : 1 à 8 types de demande : ajout, suppression, ordre
d'affichage, type masqué.
- NOUVEAU : icône d'un type : l'une des quatre d'origine, ou un
fichier .ico importé (image 64 × 64 obligatoire).
- NOUVEAU : captures d'écran autorisées ou non par type de demande.
- NOUVEAU : champs texte court, cases à cocher, case oui/non, notes
de 3, 5 ou 10 étoiles.
- AMÉLIORÉ : fenêtre « Aide » : formulaires publiés, saisie gardée
si les formulaires changent pendant la saisie.
- AMÉLIORÉ : chaque demande garde le formulaire de son envoi (nom,
icône, libellés).
- SÉCURITÉ : formulaires signés et revérifiés par le launcher ;
aucun lien accepté dans les textes.
- SERVEUR : à redéployer, avec deux blocs nginx ; trousseau à
re-signer après la diffusion de la 3.2.0.
- QUALITÉ : 13 nouveaux tests automatisés (355 au total).
------------------------------------------------------------------
CAPTURES D'ÉCRAN
PAGE « FORMULAIRES DES DEMANDES » (ADMINISTRATION)
------------------------------------------------------------------
- Nouvelle section « Captures d'écran (facultatif) » dans le
formulaire du bouton « Aide », pour tous les types de demande.
- « Ajouter une image… » : PNG, JPEG, BMP, GIF ou WebP (30 Mo au
plus).
- « Coller (Ctrl+V) » : image du presse-papiers, après Impr. écran ou
Win+Maj+S ; Ctrl+V fonctionne aussi depuis un champ de texte.
- Miniature de chaque image, avec une croix pour la retirer avant
l'envoi.
- Image reconnue à son contenu (un fichier renommé est refusé),
réduite à 3 840 px au plus et réencodée en PNG ou JPEG sans
métadonnées : ni position GPS, ni appareil, ni date.
- Le nom du fichier n'est jamais envoyé.
- Si une capture ne peut pas être envoyée, un avertissement le
précise : la demande, elle, est bien enregistrée.
- Permission « Formulaires des demandes » (feedback.forms).
- Types de demande : ajouter (8 au plus), monter, descendre,
supprimer (avec confirmation ; les demandes reçues restent
consultables), masquer.
- Réglages d'un type : nom, description, exemple de titre,
informations techniques cochées par défaut, captures d'écran
autorisées (3 au plus), icône.
- Champs : ajouter, monter, descendre, retirer ; libellé, aide,
obligatoire, aperçu dans la liste, longueur, nombre d'étoiles,
choix (2 à 20).
- Types de champs : zone de texte, texte court, liste déroulante,
cases à cocher, case oui/non, étoiles (3, 5 ou 10).
- Aperçu identique à la fenêtre « Aide » ; contrôle en direct, la
publication reste bloquée tant qu'il reste une erreur.
- Avertissement si un libellé semble demander une donnée
personnelle (e-mail, mot de passe, téléphone…).
- « Publier », « Annuler les modifications », « Revenir à
l'origine », « Recharger » ; une publication d'un autre
administrateur n'est jamais écrasée.
------------------------------------------------------------------
PAGE « DEMANDES DES JOUEURS » (ADMINISTRATION)
ICÔNES IMPORTÉES
------------------------------------------------------------------
- Liste : icône « image » pour une demande avec des captures, icône
« alerte » pour une demande contenant un lien.
- Fiche : captures d'écran en miniature, « Agrandir » / « Réduire » ;
chaque image est revérifiée avant l'affichage et rien n'est écrit
sur le PC.
- Liens : bandeau d'avertissement en haut de la fiche, liens
désamorcés (hxxps[://]site[.]com) et champs concernés surlignés ;
jamais cliquables.
- Formes reconnues : http, https, ftp, file, protocoles dangereux
(javascript:, data:…), www, domaines nus (site.ru, discord.gg/…),
adresses IP, chemins réseau, e-mails, formes déguisées (hxxp, [.],
(dot), caractères pleine chasse).
- Fichier .ico seulement (1 Mo au plus), contenant une image de
64 × 64 pixels ; les autres tailles du fichier sont ignorées.
- PNG intégré ou bitmap 24/32 bits ; 256 couleurs et moins refusés.
- Image réécrite en PNG neuf, sans métadonnées, intégrée aux
formulaires et couverte par leur signature.
- Un fichier refusé affiche un message qui explique pourquoi.
------------------------------------------------------------------
JOURNAL D'AUDIT ET FENÊTRE « AIDE »
FENÊTRE « AIDE » ET DEMANDES DES JOUEURS
------------------------------------------------------------------
- Libellés lisibles : Demande de joueur reçue, consultée, traitée,
supprimée ; Capture d'écran reçue ; Anciennes demandes supprimées ;
Anciens mois du journal supprimés ; Trousseau de signature publié ;
Version en ligne signée.
- Nouvelles catégories : « Demandes des joueurs » et « Signature
(trousseau) ».
- Fenêtre « Aide » centrée à chaque étape ; formulaire limité à une
hauteur de base, avec un ascenseur au-delà.
- La molette fait défiler le formulaire et ne change jamais une liste
déroulante fermée.
- Formulaires publiés lus à l'ouverture de la fenêtre, puis
vérifiés (règles, signature, révision).
- Types dans l'ordre choisi, avec leur icône ; types masqués
absents ; section « Captures d'écran » masquée si le type ne les
autorise pas.
- Formulaires republiés pendant la saisie : formulaire reconstruit,
saisie des champs inchangés gardée.
- Hors ligne ou formulaires refusés : formulaires d'origine.
- Menu : chaque demande s'affiche avec le formulaire de son envoi ;
filtre par type publié, « (masqué) » ou « (supprimé) ».
- Journal d'audit : « Formulaires des demandes publiés », avec le
détail des changements par type.
------------------------------------------------------------------
SÉCURITÉ ET VIE PRIVÉE
SÉCURITÉ
------------------------------------------------------------------
- Le serveur n'accepte que de vrais PNG ou JPEG : structure complète
vérifiée, rien après la fin de l'image, 16 à 4 096 px, 4 Mo au plus.
- Métadonnées retirées par le launcher puis par le serveur.
- Jeton d'envoi propre à la demande, valable 15 minutes, pour le
nombre de captures annoncé seulement.
- Plafond global porté à 1 Go (captures comprises).
- Capture affichée dans le menu seulement après un nouveau contrôle,
jamais écrite sur le disque.
- Aucune adresse IP enregistrée avec une capture ni dans l'audit de
son envoi ; nginx n'écrit pas ces requêtes dans ses journaux.
- Captures supprimées avec leur demande (suppression ou purge).
- Statut, note et suppression restent des requêtes signées.
- Formulaires signés par une clé autorisée « formulaires » dans le
trousseau ; vérifiés par chaque launcher, comme le catalogue.
- Aucun lien dans les noms, descriptions, libellés, aides et choix :
refusé par le menu, le serveur et le launcher.
- Ancienne révision refusée par le launcher.
- Publication par requête signée, avec la permission
feedback.forms ; révision attendue (rien n'est écrasé).
- Taille limitée : 256 Ko de formulaires, icônes comprises.
- Le serveur refuse une demande avec captures pour un type qui ne
les autorise pas, et une demande pour un type masqué ou supprimé.
- Une autorisation inconnue du trousseau est désormais acceptée sans
effet.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer AVANT de publier le launcher : server/deploy/
LISEZMOI.md, étape 8, puis étape 3 octies.
- nginx : ajouter le troisième bloc
« location ^~ /api/v1/feedback/attachments/ » (nginx-api.conf).
- Si Feedback:MaxTotalMegabytes est fixé dans
appsettings.Production.json, le porter à 1024.
- Sans redéploiement, les demandes arrivent, mais sans leurs
captures (le joueur est prévenu).
- À redéployer : server/deploy/LISEZMOI.md, étape 8, puis
étape 3 nonies.
- nginx : ajouter « location = /catalog/feedback-forms.json »
(nginx-catalog.conf) et « location = /api/v1/feedback/forms »
(nginx-api.conf, 300 Ko).
- Publier ensuite le launcher 3.2.0, puis donner la permission
feedback.forms et re-signer le trousseau.
- Les launchers 3.0 / 3.1 envoient toujours leurs demandes avec les
formulaires d'origine, acceptées sauf type masqué ou supprimé.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Contrats : FeedbackLinks (détection des liens), FeedbackImages
(contrôle des PNG/JPEG et retrait des métadonnées, sans
bibliothèque).
- Serveur : envoi et lecture des captures (FeedbackModule), jetons
d'envoi (FeedbackGuard), stockage à côté des demandes
(FeedbackStore).
- Launcher : ScreenshotSource (fichier ou presse-papiers, réencodage
sans métadonnées).
- Outil ui-snapshot : fenêtre « Aide » et fiche d'une demande
capturées ; section « Tenir l'outil à jour » dans son LISEZMOI.
- Tests : FeedbackContentTests.cs (nouveau), FeedbackModuleTests.cs,
- Contrats : FeedbackFormsDocument (document signé et règles),
FeedbackIcons (lecture des .ico, sans bibliothèque).
- Signature : type de contenu feedback-forms, autorisation « forms »
du trousseau.
- Serveur : FeedbackFormsStore (référence privée, historique de 20
publications, copie publique) ; route v1/feedback/forms.
- Launcher : FeedbackFormsService (lecture et vérification),
Themes/FeedbackForms.xaml (modèles partagés avec l'aperçu).
- Menu : FeedbackFormsPanelViewModel, FeedbackFormEditors.
- Outil ui-snapshot : scènes admin-page-feedback-forms-edit,
admin-page-feedback-forms-new-type, feedback-custom-kinds et
feedback-custom.
- Tests : FeedbackFormsTests.cs, FeedbackFormsModuleTests.cs,
FeedbackFormsPanelTests.cs (nouveaux), ContentSigningTests.cs,
FeedbackPanelTests.cs.
- Documentation : README.md, docs/ADMIN-ARCHITECTURE.md
(§ 11 quinquies) et server/deploy/LISEZMOI.md mis à jour.
(§ 11 sexies) 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.