Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b398e4059a | ||
|
|
69f943ea53 | ||
|
|
4c368cb1e0 | ||
|
|
eca812a718 | ||
|
|
e4f2d443e8 | ||
|
|
6e7b480db5 | ||
|
|
1c7735d25a | ||
|
|
7b153b81ee |
No files matched your search
@@ -3,9 +3,9 @@
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Company>Xbhost</Company>
|
||||
<Authors>Xbhost</Authors>
|
||||
<Copyright>© Xbhost</Copyright>
|
||||
<Company>LanisysGames</Company>
|
||||
<Authors>LanisysGames</Authors>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
<Version>1.0.0</Version>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -568,7 +568,8 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
|
||||
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.
|
||||
étape 3 nonies). Chaque demande garde le formulaire tel qu'à l'envoi. Journal d'audit : chaque publication indique
|
||||
les types ajoutés, modifiés ou supprimés par leur nom, avec le détail de chaque champ modifié.
|
||||
* **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
|
||||
|
||||
@@ -827,7 +827,7 @@ avant refusent encore un trousseau contenant `forms` et gardent le précédent (
|
||||
| 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) |
|
||||
| `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` (révision, types ajoutés, modifiés et supprimés par leur nom, signature), puis une ligne par type de demande : `feedback.form_create`, `feedback.form_update` ou `feedback.form_delete`, cible « nom du type », détail de chaque changement (nom, description, réglages, champ ajouté, supprimé ou modifié avec ce qui a changé : libellé, type, obligatoire, aide, longueur, étoiles, choix, aperçu), sur plusieurs lignes numérotées (1/2, 2/2…) au-delà de 480 caractères |
|
||||
|
||||
**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).
|
||||
|
||||
|
Before Width: | Height: | Size: 443 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 419 KiB After Width: | Height: | Size: 395 KiB |
@@ -197,7 +197,7 @@ public sealed class FeedbackModule : IApiModule
|
||||
/// <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.
|
||||
/// révision attendue (409 si quelqu'un a publié entre-temps), audit détaillé par type de demande (<see cref="FeedbackFormsAudit"/>).
|
||||
/// </summary>
|
||||
private static IResult PublishForms(FeedbackFormsPublishRequest request, HttpContext context, FeedbackFormsStore forms, AdminStore admins,
|
||||
AuditLog audit, TimeProvider time, ILogger<FeedbackModule> logger)
|
||||
@@ -222,8 +222,17 @@ public sealed class FeedbackModule : IApiModule
|
||||
"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})");
|
||||
// Une ligne par type de demande ajouté, modifié ou supprimé (son nom et tous ses changements, champs compris, sur
|
||||
// plusieurs lignes numérotées si besoin), puis la ligne de la publication (révision, résumé, signature). Écrites
|
||||
// de la dernière à la première : le journal, affiché du plus récent au plus ancien, les montre dans l'ordre.
|
||||
var changes = FeedbackFormsAudit.Changes(previous, published);
|
||||
foreach (var (action, name, details) in changes
|
||||
.SelectMany(c => FeedbackFormsAudit.Lines(c.Details).Select(details => (c.Action, c.Name, details)))
|
||||
.Reverse())
|
||||
audit.Write(action, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"« {name} »", details);
|
||||
audit.Write(FeedbackFormsAudit.Published, AuditResults.Ok, actor.Admin.Id, actor.Admin.DisplayName, actor.Key.KeyId, context.ClientIp(),
|
||||
$"révision {published.Revision}", $"{FeedbackFormsAudit.Summary(previous, published, changes)} ; signé (clé {signature.KeyId})");
|
||||
return Results.Json(FormsState(forms, admins), ApiJson.Options);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
|
||||
namespace GameLauncher.Server.Storage;
|
||||
|
||||
/// <summary>Changement d'un type de demande lors d'une publication des formulaires, pour le journal d'audit.</summary>
|
||||
/// <param name="Action">Action d'audit (<see cref="FeedbackFormsAudit.Created"/>, <see cref="FeedbackFormsAudit.Updated"/>…).</param>
|
||||
/// <param name="Name">Nom du type tel qu'affiché aux joueurs (après publication, ou avant suppression).</param>
|
||||
/// <param name="Details">Détail de chaque changement, en clair.</param>
|
||||
public sealed record FeedbackFormChange(string Action, string Name, IReadOnlyList<string> Details);
|
||||
|
||||
/// <summary>
|
||||
/// Détail d'une publication des formulaires pour le journal d'audit : une ligne « feedback.forms » (révision, résumé,
|
||||
/// signature), puis une ligne par type de demande ajouté, modifié ou supprimé, avec son nom et TOUS ses changements
|
||||
/// (présentation, réglages, chaque champ ajouté, supprimé ou modifié, et ce qui a changé dans ce champ).
|
||||
/// Un champ d'audit étant borné (<see cref="AuditLog"/>), un détail trop long est réparti sur plusieurs lignes
|
||||
/// numérotées (1/2, 2/2…) plutôt que tronqué.
|
||||
/// </summary>
|
||||
public static class FeedbackFormsAudit
|
||||
{
|
||||
public const string Published = "feedback.forms";
|
||||
public const string Created = "feedback.form_create";
|
||||
public const string Updated = "feedback.form_update";
|
||||
public const string Deleted = "feedback.form_delete";
|
||||
|
||||
/// <summary>Longueur au plus d'un détail d'audit (le journal tronque à 500 caractères).</summary>
|
||||
internal const int MaxDetailsLength = 480;
|
||||
|
||||
/// <summary>Valeur montrée en entier dans un changement (« « avant » → « après » ») jusqu'à cette longueur.</summary>
|
||||
private const int MaxShownValueLength = 80;
|
||||
|
||||
private const string Separator = " ; ";
|
||||
|
||||
/// <summary>Types ajoutés (dans l'ordre publié), modifiés, puis supprimés.</summary>
|
||||
public static IReadOnlyList<FeedbackFormChange> Changes(FeedbackFormsDocument before, FeedbackFormsDocument after)
|
||||
{
|
||||
var changes = new List<FeedbackFormChange>();
|
||||
foreach (var form in after.Forms)
|
||||
{
|
||||
var old = before.Find(form.Kind);
|
||||
if (old is null) changes.Add(new FeedbackFormChange(Created, form.Label, CreatedDetails(form)));
|
||||
else if (FormDetails(old, form) is { Count: > 0 } details) changes.Add(new FeedbackFormChange(Updated, form.Label, details));
|
||||
}
|
||||
foreach (var form in before.Forms.Where(f => after.Find(f.Kind) is null))
|
||||
changes.Add(new FeedbackFormChange(Deleted, form.Label,
|
||||
new[] { "plus proposé aux joueurs (les demandes déjà reçues de ce type restent consultables)" }));
|
||||
return changes;
|
||||
}
|
||||
|
||||
/// <summary>Résumé de la ligne « feedback.forms » : types ajoutés, modifiés, supprimés (par leur nom), ordre des types.</summary>
|
||||
public static string Summary(FeedbackFormsDocument before, FeedbackFormsDocument after, IReadOnlyList<FeedbackFormChange> changes)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
AddGroup(parts, changes, Created, "type ajouté", "types ajoutés");
|
||||
AddGroup(parts, changes, Updated, "type modifié", "types modifiés");
|
||||
AddGroup(parts, changes, Deleted, "type supprimé", "types supprimés");
|
||||
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)) parts.Add("ordre des types modifié");
|
||||
return parts.Count == 0 ? "aucun changement" : string.Join(Separator, parts);
|
||||
}
|
||||
|
||||
private static void AddGroup(List<string> parts, IReadOnlyList<FeedbackFormChange> changes, string action, string one, string many)
|
||||
{
|
||||
var names = changes.Where(c => c.Action == action).Select(c => Quote(c.Name)).ToList();
|
||||
if (names.Count > 0) parts.Add($"{names.Count} {(names.Count == 1 ? one : many)} : {string.Join(", ", names)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Détails d'un changement répartis en lignes d'audit d'au plus <paramref name="maxLength"/> caractères, numérotées
|
||||
/// s'il en faut plusieurs (aucun détail n'est perdu, un détail démesuré est coupé entre deux lignes).
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> Lines(IReadOnlyList<string> details, int maxLength = MaxDetailsLength)
|
||||
{
|
||||
// Place réservée au numéro « (12/12) ».
|
||||
var room = Math.Max(20, maxLength - 8);
|
||||
var lines = new List<string>();
|
||||
var current = "";
|
||||
foreach (var detail in details.SelectMany(d => Split(d, room)))
|
||||
{
|
||||
if (current.Length == 0) current = detail;
|
||||
else if (current.Length + Separator.Length + detail.Length <= room) current += Separator + detail;
|
||||
else
|
||||
{
|
||||
lines.Add(current);
|
||||
current = detail;
|
||||
}
|
||||
}
|
||||
if (current.Length > 0) lines.Add(current);
|
||||
return lines.Count <= 1 ? lines : lines.Select((line, i) => $"({i + 1}/{lines.Count}) {line}").ToList();
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Split(string text, int room)
|
||||
{
|
||||
for (var i = 0; i < text.Length; i += room)
|
||||
yield return text.Substring(i, Math.Min(room, text.Length - i));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Types de demande
|
||||
|
||||
private static List<string> CreatedDetails(FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (form.Hidden) details.Add("masqué (pas encore proposé aux joueurs)");
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »" : "icône importée");
|
||||
if (!form.AllowAttachments) details.Add("captures d'écran non proposées");
|
||||
if (form.TechByDefault) details.Add("« Joindre les informations techniques » coché par défaut");
|
||||
details.Add($"{form.Fields.Count} champ(s)");
|
||||
details.AddRange(form.Fields.Select(f => $"champ « {f.Label} » ({FieldSummary(f)})"));
|
||||
return details;
|
||||
}
|
||||
|
||||
private static List<string> FormDetails(FeedbackForm old, FeedbackForm form)
|
||||
{
|
||||
var details = new List<string>();
|
||||
if (old.Label != form.Label) details.Add($"nom affiché : {Quote(old.Label)} → {Quote(form.Label)}");
|
||||
if (old.Hidden != form.Hidden) details.Add(form.Hidden ? "masqué (plus proposé aux joueurs)" : "de nouveau proposé aux joueurs");
|
||||
if (old.Description != form.Description) details.Add(ValueChange("description", feminine: true, old.Description, form.Description));
|
||||
if (old.TitleHint != form.TitleHint) details.Add(ValueChange("exemple de titre", feminine: false, old.TitleHint, form.TitleHint));
|
||||
if (old.TechByDefault != form.TechByDefault)
|
||||
details.Add($"« Joindre les informations techniques » {(form.TechByDefault ? "coché" : "décoché")} par défaut");
|
||||
if (old.CustomIcon != form.CustomIcon)
|
||||
details.Add(form.CustomIcon is null ? $"icône d'origine « {form.IconKey} »"
|
||||
: old.CustomIcon is null ? "icône importée" : "icône importée remplacée");
|
||||
else if (form.CustomIcon is null && old.IconKey != form.IconKey)
|
||||
details.Add($"icône : « {old.IconKey} » → « {form.IconKey} »");
|
||||
if (old.AllowAttachments != form.AllowAttachments)
|
||||
details.Add(form.AllowAttachments ? "captures d'écran autorisées" : "captures d'écran retirées");
|
||||
|
||||
var newIds = form.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var oldIds = old.Fields.Select(f => f.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var field in old.Fields.Where(f => !newIds.Contains(f.Id)))
|
||||
details.Add($"champ supprimé : « {field.Label} »");
|
||||
foreach (var field in form.Fields)
|
||||
{
|
||||
if (old.Field(field.Id) is not { } before)
|
||||
{
|
||||
details.Add($"champ ajouté : « {field.Label} » ({FieldSummary(field)})");
|
||||
continue;
|
||||
}
|
||||
var parts = FieldDetails(before, field);
|
||||
if (parts.Count == 0) continue;
|
||||
var prefix = $"champ « {field.Label} » modifié : ";
|
||||
var combined = prefix + string.Join(", ", parts);
|
||||
// Champ très modifié (choix nombreux) : un détail par changement, chacun avec le nom du champ.
|
||||
if (combined.Length <= MaxDetailsLength / 2) details.Add(combined);
|
||||
else details.AddRange(parts.Select(p => prefix + p));
|
||||
}
|
||||
if (!old.Fields.Select(f => f.Id).Where(newIds.Contains).SequenceEqual(form.Fields.Select(f => f.Id).Where(oldIds.Contains)))
|
||||
details.Add("ordre des champs modifié");
|
||||
return details;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ Champs
|
||||
|
||||
private static List<string> FieldDetails(FeedbackField old, FeedbackField field)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (old.Label != field.Label) parts.Add($"libellé {Quote(old.Label)} → {Quote(field.Label)}");
|
||||
if (old.Kind != field.Kind) parts.Add($"type {KindText(old.Kind)} → {KindText(field.Kind)}");
|
||||
if (old.Required != field.Required) parts.Add(field.Required ? "rendu obligatoire" : "rendu facultatif");
|
||||
if ((old.Hint ?? "") != (field.Hint ?? ""))
|
||||
parts.Add(string.IsNullOrEmpty(old.Hint) ? "aide ajoutée" : string.IsNullOrEmpty(field.Hint) ? "aide retirée" : "aide modifiée");
|
||||
if (field.IsText && old.MaxLength != field.MaxLength) parts.Add($"longueur maximale {old.MaxLength} → {field.MaxLength}");
|
||||
if (field.Kind == FeedbackFieldKind.Rating && old.RatingMax != field.RatingMax)
|
||||
parts.Add($"étoiles {old.RatingMax} → {field.RatingMax}");
|
||||
if (old.ShowInList != field.ShowInList)
|
||||
parts.Add(field.ShowInList ? "affiché en aperçu dans la liste des demandes" : "retiré de l'aperçu de la liste des demandes");
|
||||
parts.AddRange(OptionDetails(old.Choices, field.Choices));
|
||||
// Filet : tout autre écart (nouveau réglage d'une version future) reste signalé.
|
||||
if (parts.Count == 0 && JsonSerializer.Serialize(old) != JsonSerializer.Serialize(field)) parts.Add("réglages modifiés");
|
||||
return parts;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> OptionDetails(IReadOnlyList<FeedbackOption> old, IReadOnlyList<FeedbackOption> now)
|
||||
{
|
||||
var oldIds = old.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
var newIds = now.Select(o => o.Id).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var option in now.Where(o => !oldIds.Contains(o.Id))) yield return $"choix ajouté « {option.Label} »";
|
||||
foreach (var option in old.Where(o => !newIds.Contains(o.Id))) yield return $"choix retiré « {option.Label} »";
|
||||
foreach (var option in now)
|
||||
if (old.FirstOrDefault(o => o.Id == option.Id) is { } before && before.Label != option.Label)
|
||||
yield return $"choix renommé « {before.Label} » → « {option.Label} »";
|
||||
if (!old.Select(o => o.Id).Where(newIds.Contains).SequenceEqual(now.Select(o => o.Id).Where(oldIds.Contains)))
|
||||
yield return "ordre des choix modifié";
|
||||
}
|
||||
|
||||
private static string FieldSummary(FeedbackField field)
|
||||
{
|
||||
var parts = new List<string> { KindText(field.Kind), field.Required ? "obligatoire" : "facultatif" };
|
||||
if (field.Kind == FeedbackFieldKind.Rating) parts.Add($"{field.RatingMax} étoiles");
|
||||
if (field.Kind is FeedbackFieldKind.Choice or FeedbackFieldKind.Checkboxes) parts.Add($"{field.Choices.Count} choix");
|
||||
if (field.ShowInList) parts.Add("en aperçu dans la liste");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
|
||||
private static string KindText(FeedbackFieldKind kind) => kind switch
|
||||
{
|
||||
FeedbackFieldKind.Multiline => "zone de texte",
|
||||
FeedbackFieldKind.Text => "texte court",
|
||||
FeedbackFieldKind.Choice => "liste de choix",
|
||||
FeedbackFieldKind.Checkboxes => "cases à cocher",
|
||||
FeedbackFieldKind.Rating => "note en étoiles",
|
||||
FeedbackFieldKind.YesNo => "case oui/non",
|
||||
_ => kind.ToString()
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ Texte
|
||||
|
||||
/// <summary>« nom : « avant » → « après » », ou « nom modifié » si l'une des valeurs est longue.</summary>
|
||||
private static string ValueChange(string name, bool feminine, string? before, string? after)
|
||||
{
|
||||
var e = feminine ? "e" : "";
|
||||
before = OneLine(before);
|
||||
after = OneLine(after);
|
||||
if (before.Length == 0) return $"{name} ajouté{e} : {Quote(after)}";
|
||||
if (after.Length == 0) return $"{name} retiré{e}";
|
||||
return before.Length <= MaxShownValueLength && after.Length <= MaxShownValueLength
|
||||
? $"{name} : {Quote(before)} → {Quote(after)}"
|
||||
: $"{name} modifié{e}";
|
||||
}
|
||||
|
||||
private static string OneLine(string? value) =>
|
||||
string.Join(' ', (value ?? "").Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
private static string Quote(string? value) => $"« {OneLine(value)} »";
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using GameLauncher.Contracts.Dtos;
|
||||
using GameLauncher.Contracts.Feedback;
|
||||
using GameLauncher.Server.Hosting;
|
||||
@@ -143,47 +142,4 @@ public sealed class FeedbackFormsStore
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ public sealed class LauncherConfig
|
||||
|
||||
public string GameName { get; set; } = "Mon Jeu";
|
||||
|
||||
public string Publisher { get; set; } = "Xbhost";
|
||||
public string Publisher { get; set; } = "LanisysGames";
|
||||
|
||||
public string Tagline { get; set; } = "";
|
||||
|
||||
|
||||
@@ -505,6 +505,9 @@ public static class AuditTexts
|
||||
"feedback.delete" => "Demande de joueur supprimée",
|
||||
"feedback.purge" => "Anciennes demandes supprimées (conservation)",
|
||||
"feedback.forms" => "Formulaires des demandes publiés",
|
||||
"feedback.form_create" => "Type de demande ajouté",
|
||||
"feedback.form_update" => "Type de demande modifié",
|
||||
"feedback.form_delete" => "Type de demande supprimé",
|
||||
"maintenance.enable" => "Mode maintenance activé",
|
||||
"maintenance.disable" => "Mode maintenance terminé",
|
||||
"maintenance.update" => "Message de la maintenance modifié",
|
||||
|
||||
@@ -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.4.1</Version>
|
||||
<Version>3.4.2</Version>
|
||||
<Authors>Lanisys</Authors>
|
||||
<Company>LanisysGames</Company>
|
||||
<Copyright>© LanisysGames</Copyright>
|
||||
|
||||
@@ -182,7 +182,8 @@ 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", "feedback.forms", "audit.purge", "keyring.publish", "release.sign" })
|
||||
"feedback.purge", "feedback.forms", "feedback.form_create", "feedback.form_update",
|
||||
"feedback.form_delete", "audit.purge", "keyring.publish", "release.sign" })
|
||||
Assert.NotEqual(action, GameLauncher.Modules.Admin.Audit.AuditTexts.Action(action));
|
||||
}
|
||||
|
||||
|
||||
@@ -67,9 +67,22 @@ public class FeedbackFormsModuleTests
|
||||
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);
|
||||
// Audit : une ligne de publication, puis une par type modifié, avec son nom et le détail de chaque champ.
|
||||
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
|
||||
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
|
||||
Assert.Contains("3 types modifiés", audit.Details);
|
||||
Assert.Contains("« Problème technique »", audit.Details);
|
||||
string Details(string name) =>
|
||||
string.Join(" ", entries.Where(e => e.Action == FeedbackFormsAudit.Updated && e.Target == $"« {name} »").Select(e => e.Details));
|
||||
var bug = Details("Problème technique");
|
||||
Assert.Contains("nom affiché : « Bug » → « Problème technique »", bug);
|
||||
Assert.Contains("champ supprimé : « Résultat attendu »", bug);
|
||||
Assert.Contains("champ ajouté : « Où cela arrive-t-il ? » (cases à cocher, obligatoire, 3 choix)", bug);
|
||||
Assert.Contains("champ ajouté : « Version affichée en jeu » (texte court, facultatif)", bug);
|
||||
Assert.Contains("champ ajouté : « Le problème revient après un redémarrage » (case oui/non, facultatif)", bug);
|
||||
Assert.Contains("champ « Note » modifié : étoiles 5 → 10", Details("Avis"));
|
||||
Assert.Contains("masqué (plus proposé aux joueurs)", Details("Autre"));
|
||||
Assert.DoesNotContain(entries, e => e.Target == "« Demande d'évolution »"); // inchangé : pas de ligne
|
||||
|
||||
// Demande avec la révision en vigueur : nouveaux champs acceptés, formulaire conservé avec la demande.
|
||||
using var client = api.CreateClient();
|
||||
@@ -166,9 +179,17 @@ public class FeedbackFormsModuleTests
|
||||
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);
|
||||
var entries = api.Services.GetRequiredService<AuditLog>().Read(50, null).Entries;
|
||||
var audit = entries.Single(e => e.Action == FeedbackFormsAudit.Published);
|
||||
Assert.Contains("5 types ajoutés", audit.Details);
|
||||
Assert.Contains("1 type supprimé : « Autre »", audit.Details);
|
||||
Assert.Equal(5, entries.Count(e => e.Action == FeedbackFormsAudit.Created));
|
||||
var created = entries.Single(e => e.Action == FeedbackFormsAudit.Created && e.Target == "« Suggestion de niveau »");
|
||||
Assert.Contains("icône importée", created.Details);
|
||||
Assert.Contains("captures d'écran non proposées", created.Details);
|
||||
Assert.Contains("champ « Message » (zone de texte, obligatoire)", created.Details);
|
||||
Assert.Contains("icône importée", entries.Single(e => e.Action == FeedbackFormsAudit.Updated && e.Target == "« Bug »").Details);
|
||||
Assert.Equal("« Autre »", entries.Single(e => e.Action == FeedbackFormsAudit.Deleted).Target);
|
||||
|
||||
using var client = api.CreateClient();
|
||||
// Nouveau type : accepté, mais sans capture d'écran.
|
||||
@@ -193,4 +214,54 @@ public class FeedbackFormsModuleTests
|
||||
Assert.Equal("Suggestion de niveau", detail.Form!.Label);
|
||||
Assert.Equal(icon, detail.Form.CustomIcon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Audit_names_every_changed_field_and_spreads_long_details_over_numbered_lines()
|
||||
{
|
||||
var before = FeedbackFormRules.Defaults();
|
||||
var many = Enumerable.Range(1, FeedbackFormRules.MaxOptions).Select(i => new FeedbackOption($"o{i}", new string('x', 50) + i)).ToList();
|
||||
var after = new FeedbackFormsDocument
|
||||
{
|
||||
Revision = 1,
|
||||
Forms = before.Forms.Select(form => form.Kind != FeedbackKinds.Bug ? form : form with
|
||||
{
|
||||
TitleHint = "Le souci en une phrase",
|
||||
TechByDefault = false,
|
||||
Fields = form.Fields.Select(field => field.Id switch
|
||||
{
|
||||
"severity" => field with { Label = "Gravité du problème", Required = false, ShowInList = false, Options = many },
|
||||
"steps" => field with { Hint = "Autre aide." },
|
||||
"actual" => field with { MaxLength = 800 },
|
||||
_ => field
|
||||
}).Reverse().ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var change = Assert.Single(FeedbackFormsAudit.Changes(before, after));
|
||||
Assert.Equal(FeedbackFormsAudit.Updated, change.Action);
|
||||
Assert.Equal("Bug", change.Name);
|
||||
Assert.Equal("1 type modifié : « Bug »", FeedbackFormsAudit.Summary(before, after, new[] { change }));
|
||||
|
||||
// Rien n'est perdu : plusieurs lignes numérotées, chacune sous la limite d'un champ du journal.
|
||||
var lines = FeedbackFormsAudit.Lines(change.Details);
|
||||
Assert.True(lines.Count > 1);
|
||||
Assert.All(lines, line => Assert.True(line.Length <= FeedbackFormsAudit.MaxDetailsLength, line));
|
||||
Assert.StartsWith("(1/", lines[0]);
|
||||
Assert.StartsWith($"({lines.Count}/{lines.Count}) ", lines[^1]);
|
||||
var all = string.Join(" ", lines);
|
||||
Assert.Contains("exemple de titre : « Le problème en une phrase » → « Le souci en une phrase »", all);
|
||||
Assert.Contains("« Joindre les informations techniques » décoché par défaut", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : libellé « Gravité » → « Gravité du problème »", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : rendu facultatif", all);
|
||||
Assert.Contains("champ « Gravité du problème » modifié : retiré de l'aperçu de la liste des demandes", all);
|
||||
foreach (var option in many) Assert.Contains($"choix ajouté « {option.Label} »", all);
|
||||
Assert.Contains("choix retiré « Mineur »", all);
|
||||
Assert.Contains("champ « Étapes pour reproduire » modifié : aide modifiée", all);
|
||||
Assert.Contains("champ « Résultat obtenu » modifié : longueur maximale 1500 → 800", all);
|
||||
Assert.Contains("ordre des champs modifié", all);
|
||||
|
||||
// Publication sans changement : pas de ligne par type.
|
||||
Assert.Empty(FeedbackFormsAudit.Changes(before, before with { Revision = 1 }));
|
||||
Assert.Equal("aucun changement", FeedbackFormsAudit.Summary(before, before, Array.Empty<FeedbackFormChange>()));
|
||||
}
|
||||
}
|
||||
@@ -621,6 +621,15 @@ internal sealed class SampleWorld
|
||||
Add(2.01, "feedback.submit", AuditResults.Ok, null, $"{game} FB-7K2M-Q9XD", "Bug ; 2 capture(s) d'écran annoncée(s)", ip: null);
|
||||
Add(9, "feedback.submit", AuditResults.Denied, null, details: "3 demande(s) refusée(s) : limite par adresse atteinte", ip: null);
|
||||
Add(30, "feedback.update", AuditResults.Ok, "Camille", $"{game} FB-3PQR-8WXT", "Nouvelle → En cours ; note modifiée");
|
||||
// Publication des formulaires : une ligne de résumé, puis une par type de demande (nom et détail des champs).
|
||||
Add(12, "feedback.forms", AuditResults.Ok, "Camille", "révision 4",
|
||||
$"1 type ajouté : « Suggestion de niveau » ; 1 type modifié : « Problème technique » ; signé (clé {OwnerKeyId})");
|
||||
Add(12, "feedback.form_create", AuditResults.Ok, "Camille", "« Suggestion de niveau »",
|
||||
"icône importée ; captures d'écran non proposées ; 1 champ(s) ; champ « Message » (zone de texte, obligatoire)");
|
||||
Add(12, "feedback.form_update", AuditResults.Ok, "Camille", "« Problème technique »",
|
||||
"nom affiché : « Bug » → « Problème technique » ; champ supprimé : « Résultat attendu » ; champ « Gravité » modifié : " +
|
||||
"rendu facultatif, choix ajouté « Très grave », choix renommé « Plantage » → « Plantage du jeu » ; " +
|
||||
"champ ajouté : « Version affichée en jeu » (texte court, facultatif)");
|
||||
Add(1, "admins.invite", AuditResults.Ok, OwnerName, "Nicolas", "nouvel appareil pour Nicolas");
|
||||
Add(3, "admins.invite", AuditResults.Ok, OwnerName, "Alex", "Alex (editor)");
|
||||
Add(5, "auth.enroll", AuditResults.Ok, null, "Léo", "clé en attente d'approbation", "198.51.100.7");
|
||||
|
||||
@@ -1,36 +1,66 @@
|
||||
# Launcher 3.4.1
|
||||
# Launcher 3.4.2
|
||||
LAUNCHER - NOTES DE MISE À JOUR
|
||||
Version de maintenance du launcher - Mise à jour du 10 novembre 2026
|
||||
=========================================================================
|
||||
|
||||
Version de maintenance : le launcher fonctionne exactement comme en 3.4.0. Elle accompagne des améliorations de nos outils de développement.
|
||||
Version de maintenance : pour les joueurs, le launcher fonctionne exactement comme en 3.4.1. Le nom de l'éditeur par défaut est désormais LanisysGames, et le journal d'audit du menu d'administration détaille les modifications des formulaires des demandes.
|
||||
|
||||
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
|
||||
Le launcher se met à jour automatiquement. Administrateurs : mettre à jour la Launcher API (voir SERVEUR).
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
EN BREF
|
||||
------------------------------------------------------------------
|
||||
- INFO : changement du nom de l'éditeur par défaut. Les propriétés
|
||||
de GameLauncher.exe (clic droit > Propriétés > Détails)
|
||||
indiquent désormais LanisysGames.
|
||||
- INFO : aucun changement dans l'utilisation du launcher :
|
||||
installation, mises à jour, vérification et réparation des
|
||||
jeux restent identiques.
|
||||
jeux restent identiques. Vos jeux, vos paramètres et vos
|
||||
dossiers d'installation sont conservés.
|
||||
- SÉCURITÉ : comme pour chaque version, la signature numérique
|
||||
et l'empreinte SHA-256 sont vérifiées avant l'installation.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
MENU D'ADMINISTRATION
|
||||
------------------------------------------------------------------
|
||||
- Journal d'audit : une ligne par type de demande ajouté, modifié
|
||||
ou supprimé lors d'une publication des formulaires, avec son nom
|
||||
(« Type de demande ajouté », « modifié », « supprimé »).
|
||||
- Type modifié : tous ses changements (nom affiché, description,
|
||||
exemple de titre, masqué, icône, captures d'écran,
|
||||
informations techniques) et chaque champ ajouté, supprimé ou
|
||||
modifié, avec ce qui a changé (libellé, type, obligatoire,
|
||||
aide, longueur, étoiles, choix, aperçu, ordre).
|
||||
- Ligne de la publication : révision, types ajoutés, modifiés et
|
||||
supprimés par leur nom, ordre des types, signature.
|
||||
- Détail trop long : réparti sur plusieurs lignes numérotées
|
||||
(1/2, 2/2...), rien n'est tronqué.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
SERVEUR
|
||||
------------------------------------------------------------------
|
||||
- Aucun changement.
|
||||
- Launcher API à mettre à jour (server/deploy/LISEZMOI.md,
|
||||
étape 8) pour obtenir le détail des formulaires dans le
|
||||
journal d'audit. Aucun réglage ni bloc nginx à ajouter.
|
||||
|
||||
|
||||
------------------------------------------------------------------
|
||||
DÉVELOPPEMENT
|
||||
------------------------------------------------------------------
|
||||
- UiSnapshot : captures d'écran au format WebP (--format webp,
|
||||
--quality 1 à 100, 100 = sans perte), en plus du PNG.
|
||||
- UiSnapshot : paquet SkiaSharp (encodage WebP), utilisé par
|
||||
l'outil seulement.
|
||||
- Changement du nom de l'éditeur par défaut (LauncherConfig) et
|
||||
des propriétés communes des projets (Directory.Build.props :
|
||||
société, auteurs, copyright).
|
||||
- Documentation : captures docs/preview.png et
|
||||
docs/preview-update.png mises à jour (changement de
|
||||
l'éditeur), avec une version WebP de chacune.
|
||||
- Serveur : FeedbackFormsAudit (détail des publications des
|
||||
formulaires) remplace FeedbackFormsStore.Describe.
|
||||
- UiSnapshot : exemples des nouvelles lignes dans la page
|
||||
« Journal d'audit ».
|
||||
- Tests : 1 nouveau test, 3 tests complétés.
|
||||
|
||||
|
||||
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.
|
||||
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |