Compare commits

...
9 Commits
Author SHA1 Message Date
nicolas c9a491622b Merge pull request 'Dev/2.5.0' (#125) from dev/2.5.0 into master
Reviewed-on: #125
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-08 15:55:01 +01:00
nicolas c210754a18 Version 2.5.0 2026-11-08 15:46:22 +01:00
nicolas 389593a3b4 Plein écran : fenêtre agrandie sur la zone utile de l'écran, barre des tâches toujours visible 2026-11-08 15:28:20 +01:00
nicolas 22ee3a6dee Documentation
Message : Docs : plein écran, règles responsive pour les futurs modules, nom de l'appareil dans l'audit
2026-11-08 15:22:18 +01:00
nicolas a72761ccd0 Boîtes de dialogue : taille adaptée à l’écran de la fenêtre parente
Message : Dialogues : taille limitée à l'écran de la fenêtre parente, message long qui défile
2026-11-08 15:21:52 +01:00
nicolas 62011342ec Menu d’administration : plein écran et responsive
Message : Menu admin : plein écran, bandeaux pleine largeur, pages en deux colonnes qui défilent sur petit écran
2026-11-08 15:21:13 +01:00
nicolas 0f93b784f5 Fenêtre principale : plein écran et responsive
Message : Fenêtre principale : bouton plein écran, colonnes fluides, présentation du jeu qui défile
2026-11-08 15:20:44 +01:00
nicolas 3ad27965fd Plein écran : socle réutilisable
Message : Plein écran : comportement WindowModeAssist (F11, mode mémorisé), mise en page responsive ScaleToFitDecorator
2026-11-08 15:20:17 +01:00
nicolas 69e710d3b7 Audit : nom de l’appareil défini à l’invitation au lieu du nom Windows
Message : Audit : afficher le nom de l'appareil choisi à l'invitation, ne plus envoyer le nom Windows du PC
2026-11-08 15:19:04 +01:00
30 changed files with 1067 additions and 221 deletions

No files matched your search

+14 -1
View File
@@ -288,6 +288,16 @@ Deux fenêtres distinctes :
Au premier démarrage d'une version qui sépare les paramètres, les réglages *Lancement du jeu*
sont repris de ceux du jeu affiché par défaut.
### Plein écran
La fenêtre principale et le menu d'administration ont deux modes : **fenêtré** (taille d'origine) ou
**plein écran** (fenêtre agrandie sur tout l'écran, la barre des tâches restant visible). Bascule : bouton entre « Réduire » et
« Fermer », ou **F11**. Le mode de chaque fenêtre est retenu pour la fois suivante. Les fenêtres ne se
redimensionnent jamais par les bords ou les coins ; la mise en page s'adapte à la place disponible
(plein écran : colonnes plus larges ; petit écran ou mise à l'échelle 125-150 % : contenu réduit d'un
bloc, sans rien rogner). Les autres fenêtres (paramètres, notes de version agrandies, boîtes de
dialogue) gardent leur taille fixe.
### Zone de notification (icônes cachées, près de l'horloge)
Deux options des *Paramètres du launcher* (désactivées par défaut) :
@@ -312,6 +322,7 @@ referme aussitôt. Il se ferme dès qu'il perd le premier plan (clic ailleurs) o
├─ launcher-settings.json paramètres du launcher (communs à tous les jeux)
├─ self-update.json mise à jour du launcher (§7)
├─ launcher-state.json dernier jeu affiché (plusieurs jeux)
├─ window-modes.json plein écran ou fenêtré, par fenêtre (« main », « admin »)
├─ logs\launcher.log journal du launcher (roue crantée › Ouvrir le dossier des journaux)
└─ Games\<GameId>\settings.json paramètres de chaque jeu (menu « … » › Paramètres du jeu)
```
@@ -595,13 +606,15 @@ servi aux joueurs (page **Signature**, voir plus bas). Ajoutée ensuite : la pag
* **Page « Journal d'audit »** (permission `audit.read` : propriétaires et administrateurs) : connexions,
invitations, approbations, publications, modifications et refus, les plus récents d'abord (par pages
de 200). Filtres par **période** (aujourd'hui, 7 ou 30 derniers jours, ce mois-ci, mois précédent, ou dates
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, IP, clé…). La période est demandée au
« du / au » au format jj/mm/aaaa), catégorie, résultat et texte (nom, appareil, IP, clé…). La période est demandée au
serveur (paramètre `before`) et les pages sont chargées jusqu'à son début (5000 entrées au plus) ; l'**export
CSV** reprend toutes les entrées filtrées, période comprise. Le journal se surveille lui-même : chaque
**consultation** (ouverture, « Actualiser », période changée) est inscrite par le serveur (« Journal d'audit
consulté » : auteur, appareil, IP, nombre d'entrées lues), et chaque **export** l'est avant l'écriture du
fichier (« Journal d'audit exporté (CSV) » : nombre d'entrées, période, filtres) ; si le serveur ne peut pas
l'inscrire, le fichier n'est pas écrit. Catégorie de filtre « Consultations et exports du journal ».
Chaque entrée indique l'**appareil** sous le nom choisi à l'enregistrement de sa clé (« J'ai une
invitation »), jamais le nom Windows du PC (colonne « Appareil » de l'export CSV).
* **Page « Fichiers du serveur »** (permission `files.manage` : propriétaires et administrateurs) : contenu du
site public (dossiers, tailles, dates, espace libre du disque) et état de chaque fichier : **Utilisé** (archive,
fichier d'une version, exécutable du launcher, image du catalogue), **Protégé** (`.json`), **Inutilisé**
+51
View File
@@ -139,6 +139,48 @@ public sealed record AdminPanelDescriptor(
module, et son `DataTemplate` dans `Themes/Admin.xaml` (ou un dictionnaire dédié fusionné dans
`App.xaml`). La fenêtre d'administration elle-même ne change pas.
### 5.3 Affichage : mode fenêtré ou plein écran, mise en page responsive (✅)
Deux modes seulement, pour la fenêtre principale et le menu d'administration (les autres fenêtres —
paramètres, notes agrandies, boîtes de dialogue — gardent leur taille fixe) : **fenêtré** (taille
d'origine, jamais redimensionnable par les bords ou les coins) et **plein écran** au sens d'une fenêtre
Windows agrandie (toute la zone utile de l'écran où se trouve la fenêtre, la barre des tâches restant visible,
même en masquage automatique ; pas de plein écran exclusif comme un jeu). Bascule : bouton entre « Réduire » et « Fermer », ou **F11**.
Le mode est mémorisé par fenêtre (`%AppData%\…\GameLauncher\window-modes.json`, ex.
`{ "main": true, "admin": false }` ; simple préférence, sans donnée personnelle).
* `Controls/WindowModeAssist` (propriétés attachées, aucun code dans la fenêtre) :
`ui:WindowModeAssist.AllowFullScreen="True"` + `ui:WindowModeAssist.PersistenceKey="clé"` sur la
fenêtre ; bouton `<Button Style="{StaticResource WindowModeButtonStyle}" />` (icône et infobulle
selon le mode, masqué si la fenêtre ne le permet pas). En plein écran, les ressources de la fenêtre
nommées `WindowChrome.*` (CornerRadius, Thickness : arrondis, marge de l'ombre) passent à 0 ; elles
sont utilisées en `DynamicResource`. En mode fenêtré, la taille est limitée à la zone utile de
l'écran de la fenêtre. Déplacement par la barre de titre : `WindowModeAssist.DragMove(window)` (sans
effet en plein écran) ; réaffichage d'une fenêtre réduite : `WindowModeAssist.RestoreFromMinimized`
(jamais `WindowState = Normal`, qui ferait quitter le plein écran).
* `Controls/ScaleToFitDecorator` (responsive) : `DesignWidth` × `DesignHeight` = taille du contenu
en mode fenêtré. Plus de place (plein écran) : le contenu s'étale (colonnes fluides, listes et
ascenseurs) ; moins de place (petit écran, mise à l'échelle Windows 125-150 %) : il est mis en page
à cette taille puis réduit d'un bloc. Rien n'est jamais rogné : tous les boutons restent
accessibles, tous les textes visibles. Le contenu n'est jamais agrandi.
* **Nouvelle page d'administration** (module futur) : elle s'affiche dans la fenêtre d'administration
et profite donc du plein écran sans rien déclarer. Règles de mise en page pour qu'elle reste
lisible dans les deux modes :
* ne pas fixer la largeur d'un bloc de texte (`MaxWidth` sur un bandeau…) : texte en
`TextWrapping="Wrap"` dans un `DockPanel` ou une colonne `*` d'un `Grid`, jamais dans un
`StackPanel` horizontal (il ne revient alors jamais à la ligne) ;
* listes de tuiles : `WrapPanel` ; libellés sur une ligne : `TextTrimming="CharacterEllipsis"` +
infobulle ;
* colonnes de largeur fixe : seulement pour une liste de navigation (≤ 260) ; la partie
principale en `*` ;
* page en deux colonnes qui défilent séparément : `HasSplitLayout => true` ; la page reçoit la
hauteur visible (au moins 360 px, sinon la page entière défile en plus) ;
* bandeaux d'erreur et de résultat : reprendre ceux des pages existantes (`AdminErrorBannerStyle`,
`AdminInfoBannerStyle`, icône à gauche dans un `DockPanel`).
* **Futur module du launcher** affiché dans sa propre fenêtre : `AllowFullScreen` seulement si la fenêtre
contient assez d'informations pour le mériter, avec une `PersistenceKey` propre et un
`ScaleToFitDecorator` autour du contenu.
## 6. Authentification par clé publique/privée
### 6.1 Clés
@@ -562,6 +604,15 @@ que le serveur accepte ; ses refus sont affichés tels quels (le serveur reste s
sauter l'annonce, mais pas la lecture, elle-même tracée) ; export CSV des entrées filtrées (période
comprise), toutes les cellules entre guillemets et celles qui commenceraient par `= + - @` préfixées d'une apostrophe (certaines valeurs
viennent de requêtes anonymes ou de saisies : pas de formule exécutée par le tableur).
* **Appareil** : chaque entrée liée à une clé affiche le **nom de l'appareil choisi à l'enregistrement de
la clé** (formulaire « J'ai une invitation »), jamais le nom Windows du PC. Le serveur l'ajoute à la
lecture (`AuditEntry.KeyLabel`, d'après `admins.json`) : il n'est jamais écrit dans les fichiers du
journal (ajout seul), et les anciennes entrées en profitent aussi ; colonne « Appareil » dans l'export
CSV, recherche comprise. À la connexion (`auth.login`), le détail est « appareil « Nom » · GameLauncher
x.y.z » : le launcher n'envoie plus le nom Windows du PC (`LoginRequest.ClientName` = logiciel et
version) et le serveur retire celui qu'ajoutaient les launchers jusqu'à la version 2.4.1 (il n'est ni
journalisé ni conservé dans la session). Les lignes déjà écrites par un ancien serveur gardent leur texte
d'origine (journal en ajout seul).
* Après une modification, la page se recharge séparément : un échec du rechargement n'efface ni le
résultat annoncé ni un code d'invitation (affiché avant le rechargement). Pas de nouvelle invitation
tant qu'un code non transmis est affiché.
@@ -29,7 +29,8 @@ public sealed class AuditModule : IApiModule
api.MapPost(ApiRoutes.AuditExport, RecordExport).RequirePermission(AdminPermissions.AuditRead).RequireSignedRequest();
}
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations)
private static IResult Read(int? limit, string? cursor, string? before, HttpContext context, AuditLog audit, AuditConsultations consultations,
AdminStore store)
{
if (cursor is { Length: > 32 }) return ApiResults.BadRequest("Curseur invalide.");
DateTimeOffset? until = null;
@@ -43,7 +44,7 @@ public sealed class AuditModule : IApiModule
until = parsed;
}
var page = audit.Read(limit ?? 50, cursor, until);
var page = WithDeviceNames(audit.Read(limit ?? 50, cursor, until), store);
// Consultation inscrite APRÈS la lecture (la page renvoyée montre le journal tel qu'il était). Première page
// (ouverture, « Actualiser », période changée) : toujours ; pages suivantes (« Charger plus ») : une fois par
@@ -61,6 +62,25 @@ public sealed class AuditModule : IApiModule
return Results.Json(page, ApiJson.Options);
}
/// <summary>
/// Nom de l'appareil de chaque clé (défini à l'enregistrement de la clé, avec l'invitation), ajouté à la lecture :
/// les anciennes entrées en profitent aussi, et le journal sur le disque reste inchangé (ajout seul).
/// </summary>
internal static AuditPage WithDeviceNames(AuditPage page, AdminStore store)
{
if (page.Entries.All(e => e.KeyId is null && e.KeyLabel is null)) return page;
var database = store.Read();
var labels = new Dictionary<string, string?>(StringComparer.Ordinal);
var entries = page.Entries.Select(entry =>
{
if (entry.KeyId is not { } keyId) return entry with { KeyLabel = null };
if (!labels.TryGetValue(keyId, out var label))
labels[keyId] = label = database.FindKey(keyId) is { } found ? Auth.AuthModule.DeviceName(found.Key.Label) : null;
return entry with { KeyLabel = label }; // seul le serveur renseigne ce champ (jamais lu depuis le fichier)
}).ToList();
return page with { Entries = entries };
}
private static IResult RecordExport(HttpContext context, AuditLog audit)
{
AuditExportRequest? request;
@@ -97,7 +97,7 @@ public sealed class AuthModule : IApiModule
return ApiResults.Unauthorized(LoginRefused);
}
var clientName = ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength);
var clientName = ClientSoftware(ApiText.CleanLine(request.ClientName, ApiText.MaxClientNameLength));
var (token, session) = sessions.Create(admin.Id, key.KeyId, ip, clientName);
// Date de dernière utilisation : enregistrée au plus une fois par heure (évite de réécrire
// admins.json à chaque connexion).
@@ -117,7 +117,10 @@ public sealed class AuthModule : IApiModule
}
}
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip, details: clientName);
// Appareil : le nom choisi à l'enregistrement de la clé (invitation), jamais le nom Windows du PC.
var device = $"appareil « {DeviceName(key.Label)} »";
audit.Write("auth.login", AuditResults.Ok, admin.Id, admin.DisplayName, key.KeyId, ip,
details: clientName is null ? device : $"{device} · {clientName}");
var database = store.Read();
var authenticated = new AuthenticatedAdmin(session, admin, key, database.PermissionsOf(admin.Role), database.GroupName(admin.Role));
return Results.Json(new LoginResponse
@@ -130,6 +133,23 @@ public sealed class AuthModule : IApiModule
}, ApiJson.Options);
}
/// <summary>Nom de l'appareil tel que défini à l'enregistrement de la clé (invitation).</summary>
internal static string DeviceName(string? keyLabel) => string.IsNullOrWhiteSpace(keyLabel) ? "sans nom" : keyLabel;
/// <summary>
/// Logiciel client (« GameLauncher 2.5.0 »), pour le journal. Les launchers jusqu'à la version 2.4.1 y ajoutaient le
/// nom Windows du PC entre parenthèses (« GameLauncher 2.4.1 (DESKTOP-ABC123) ») : il est retiré, seul le nom de
/// l'appareil choisi à l'enregistrement de la clé identifie le poste (et le nom Windows n'est pas conservé).
/// </summary>
internal static string? ClientSoftware(string? clientName)
{
if (clientName is null) return null;
if (clientName.StartsWith("GameLauncher ", StringComparison.Ordinal) && clientName.EndsWith(')') &&
clientName.IndexOf(" (", StringComparison.Ordinal) is var open and > 0)
return clientName[..open];
return clientName;
}
private static IResult Logout(HttpContext context, SessionStore sessions, AuditLog audit)
{
var admin = context.Admin();
@@ -13,6 +13,14 @@ public sealed record AuditEntry
public Guid? ActorId { get; init; }
public string? ActorName { get; init; }
public string? KeyId { get; init; }
/// <summary>
/// Nom de l'appareil de la clé <see cref="KeyId"/>, tel que défini à l'enregistrement de la clé (invitation).
/// Jamais écrit dans les fichiers du journal : ajouté par le serveur à la lecture (null : clé inconnue, ou serveur
/// plus ancien).
/// </summary>
public string? KeyLabel { get; init; }
public string? Ip { get; init; }
public string? Target { get; init; }
public string? Details { get; init; }
@@ -428,7 +428,10 @@ public sealed class AuditItem
if (!string.IsNullOrEmpty(entry.Target)) parts.Add(entry.Target);
if (!string.IsNullOrEmpty(entry.Details)) parts.Add(entry.Details);
DetailText = string.Join(" — ", parts);
// Appareil : nom choisi à l'enregistrement de la clé (invitation), fourni par le serveur — jamais le nom Windows.
DeviceText = string.IsNullOrEmpty(entry.KeyLabel) ? "" : $"Appareil « {entry.KeyLabel} »";
var origin = new List<string>();
if (DeviceText.Length > 0) origin.Add(DeviceText);
if (!string.IsNullOrEmpty(entry.Ip)) origin.Add($"IP {entry.Ip}");
if (!string.IsNullOrEmpty(entry.KeyId)) origin.Add($"clé {entry.KeyId}");
OriginText = string.Join(" · ", origin);
@@ -447,6 +450,10 @@ public sealed class AuditItem
public bool IsDenied => Entry.Result == AuditResults.Denied;
public bool IsFailed => Entry.Result == AuditResults.Failed;
public string ActorText { get; }
/// <summary>« Appareil « PC bureau » » (vide : entrée sans clé, ou clé inconnue du serveur).</summary>
public string DeviceText { get; }
public string DetailText { get; }
public bool HasDetail => DetailText.Length > 0;
public string OriginText { get; }
@@ -524,14 +531,14 @@ public static class AuditTexts
public static string ToCsv(IEnumerable<AuditItem> items)
{
var csv = new StringBuilder();
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;IP;Clé;Cible;Détails");
csv.AppendLine("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails");
foreach (var item in items)
{
var e = item.Entry;
csv.AppendLine(string.Join(';', new[]
{
e.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
item.ActionText, e.Action, item.ResultText, e.ActorName, e.Ip, e.KeyId, e.Target, e.Details
item.ActionText, e.Action, item.ResultText, e.ActorName, e.KeyLabel, e.Ip, e.KeyId, e.Target, e.Details
}.Select(Cell)));
}
return csv.ToString();
@@ -14,7 +14,7 @@ public sealed class AdminLoginService
private readonly IAdminTpm? _tpm;
private readonly IReadOnlyList<(string KeyId, byte[] PublicKey)> _rootKeys;
/// <param name="clientName">Description du poste pour le journal d'audit, ex. « GameLauncher 1.4.0 (PC-BUREAU) ».</param>
/// <param name="clientName">Logiciel pour le journal d'audit, ex. « GameLauncher 2.5.0 » (l'appareil est identifié par sa clé).</param>
/// <param name="tpm">Puce de sécurité de ce PC (null : clés TPM indisponibles).</param>
/// <param name="rootKeys">
/// Clés racine du launcher (update-signing.pub) : une clé d'administrateur identique est refusée, quel que soit
@@ -59,6 +59,10 @@ public sealed class AdminEnvironment
/// <summary>Faux dans les tests : la surveillance de session est déclenchée à la main.</summary>
public bool StartTimers { get; init; } = true;
/// <summary>Poste, pour le journal d'audit du serveur.</summary>
public string ClientName => $"GameLauncher {LauncherVersion} ({System.Environment.MachineName})";
/// <summary>
/// Logiciel, pour le journal d'audit du serveur (« GameLauncher 2.5.0 »). Le poste y est identifié par le nom de
/// l'appareil choisi à l'enregistrement de sa clé (invitation), que le serveur connaît : jamais par le nom Windows du
/// PC, qui n'est pas envoyé.
/// </summary>
public string ClientName => $"GameLauncher {LauncherVersion}";
}
+3
View File
@@ -255,6 +255,9 @@ public partial class App : Application, ILauncherRestartHost
var version = VersionUtil.Display(_version);
Log.Info($"Démarrage du launcher {version} ({catalog.Games.Count} jeu(x))");
// Mode d'affichage (plein écran ou fenêtré) mémorisé pour chaque fenêtre qui le permet. Pas en mode --capture.
Controls.WindowModeAssist.Store = readOnly ? null : new WindowModeStore(Path.Combine(catalog.DataDirectory, WindowModeStore.FileName));
var window = new Views.MainWindow();
MainWindow = window;
_mainWindow = window;
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace GameLauncher.Controls;
/// <summary>
/// Mise en page responsive d'une fenêtre à deux modes (fenêtré / plein écran, voir <see cref="WindowModeAssist"/>) :
/// <list type="bullet">
/// <item>place disponible plus GRANDE que la taille minimale de mise en page (<see cref="DesignWidth"/> ×
/// <see cref="DesignHeight"/>, un peu en dessous de la taille fenêtrée : le contenu défile déjà verticalement) : le contenu
/// occupe toute la place (colonnes, listes et ascenseurs s'adaptent) ;</item>
/// <item>place plus PETITE (petit écran, mise à l'échelle Windows 125-150 %) : le contenu est mis en page à la taille de
/// conception puis réduit d'un bloc, sans rien rogner ni masquer : tous les boutons restent accessibles, tous les textes
/// visibles.</item>
/// </list>
/// Le contenu n'est jamais agrandi : en plein écran sur un grand écran, il gagne de la place, pas des pixels flous.
/// </summary>
public sealed class ScaleToFitDecorator : Decorator
{
public static readonly DependencyProperty DesignWidthProperty = DependencyProperty.Register(
nameof(DesignWidth), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
public static readonly DependencyProperty DesignHeightProperty = DependencyProperty.Register(
nameof(DesignHeight), typeof(double), typeof(ScaleToFitDecorator),
new FrameworkPropertyMetadata(0.0, FrameworkPropertyMetadataOptions.AffectsMeasure), IsValidSize);
/// <summary>Largeur minimale de mise en page du contenu (0 : jamais réduit en largeur).</summary>
public double DesignWidth
{
get => (double)GetValue(DesignWidthProperty);
set => SetValue(DesignWidthProperty, value);
}
/// <summary>Hauteur minimale de mise en page du contenu (0 : jamais réduit en hauteur).</summary>
public double DesignHeight
{
get => (double)GetValue(DesignHeightProperty);
set => SetValue(DesignHeightProperty, value);
}
/// <summary>Échelle appliquée (1 : taille réelle ; en dessous, contenu réduit).</summary>
public double Scale { get; private set; } = 1;
/// <summary>Plus petite échelle acceptée : en dessous, les ascenseurs du contenu prennent le relais.</summary>
private const double MinScale = 0.5;
/// <summary>Écart négligeable (arrondis) : pas de réduction, qui rendrait le texte un peu flou pour rien.</summary>
private const double Tolerance = 0.995;
private static bool IsValidSize(object value) => value is double d && !double.IsNaN(d) && !double.IsInfinity(d) && d >= 0;
private double ScaleFor(Size available)
{
var scale = 1.0;
if (DesignWidth > 0 && !double.IsInfinity(available.Width) && available.Width < DesignWidth)
scale = Math.Min(scale, available.Width / DesignWidth);
if (DesignHeight > 0 && !double.IsInfinity(available.Height) && available.Height < DesignHeight)
scale = Math.Min(scale, available.Height / DesignHeight);
return scale >= Tolerance ? 1 : Math.Max(MinScale, scale);
}
protected override Size MeasureOverride(Size constraint)
{
if (Child is not { } child) return default;
var scale = ScaleFor(constraint);
child.Measure(new Size(constraint.Width / scale, constraint.Height / scale));
var desired = child.DesiredSize;
return new Size(Math.Min(desired.Width * scale, constraint.Width), Math.Min(desired.Height * scale, constraint.Height));
}
protected override Size ArrangeOverride(Size arrangeSize)
{
if (Child is not { } child) return arrangeSize;
var scale = ScaleFor(arrangeSize);
Scale = scale;
// Mis en page à la taille « non réduite », puis réduit à l'affichage (le clic et les infobulles suivent la réduction).
child.Arrange(new Rect(0, 0, arrangeSize.Width / scale, arrangeSize.Height / scale));
if (scale >= 1)
{
if (child.RenderTransform is not MatrixTransform { Matrix.IsIdentity: true }) child.RenderTransform = Transform.Identity;
}
else if (child.RenderTransform is not ScaleTransform current || Math.Abs(current.ScaleX - scale) > 0.0001)
{
child.RenderTransform = new ScaleTransform(scale, scale);
}
return arrangeSize;
}
}
@@ -0,0 +1,363 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Input;
using System.Windows.Interop;
using System.Windows.Media;
using GameLauncher.Platform;
namespace GameLauncher.Controls;
/// <summary>
/// Deux modes d'affichage seulement pour une fenêtre sans bordure : FENÊTRÉ (taille d'origine, jamais redimensionnable
/// par les bords ou les coins) ou PLEIN ÉCRAN, au sens d'une fenêtre Windows agrandie : toute la zone utile de l'écran où
/// se trouve la fenêtre, la barre des tâches restant visible (même en masquage automatique). Ce n'est pas le plein écran
/// exclusif d'un jeu.
/// <para>
/// Une fenêtre s'y inscrit en XAML, sans code : <c>ui:WindowModeAssist.AllowFullScreen="True"</c> (et, pour retrouver
/// le mode au prochain affichage, <c>ui:WindowModeAssist.PersistenceKey="clé"</c>). Elle reçoit alors :
/// </para>
/// <list type="bullet">
/// <item>la touche F11 et la commande <see cref="ToggleFullScreenCommand"/> (bouton prêt à l'emploi : style
/// « WindowModeButtonStyle » de Themes/Controls.xaml, icône et infobulle selon le mode) ;</item>
/// <item>les ressources « WindowChrome.* » de la fenêtre (CornerRadius, Thickness : arrondis, marge de l'ombre) mises à 0
/// en plein écran, puis restaurées : les éléments qui les utilisent en DynamicResource suivent le mode ;</item>
/// <item>en mode fenêtré, une taille limitée à la zone utile de SON écran (petits écrans, mise à l'échelle 125-150 %).</item>
/// </list>
/// Les fenêtres qui ne s'y inscrivent pas (boîtes de dialogue, paramètres…) gardent leur taille fixe.
/// </summary>
public static class WindowModeAssist
{
/// <summary>Bascule fenêtré / plein écran de la fenêtre qui contient l'élément (F11).</summary>
public static readonly RoutedUICommand ToggleFullScreenCommand =
new("Plein écran", nameof(ToggleFullScreenCommand), typeof(WindowModeAssist));
/// <summary>Préfixe des ressources de la fenêtre (CornerRadius, Thickness) remises à zéro en plein écran.</summary>
public const string ChromeResourcePrefix = "WindowChrome.";
/// <summary>
/// Modes mémorisés d'une fenêtre à l'autre (<see cref="PersistenceKeyProperty"/>). Null : rien n'est mémorisé
/// (mode --capture, tests). Fixé par l'application avant l'affichage des fenêtres.
/// </summary>
public static WindowModeStore? Store { get; set; }
// ------------------------------------------------------------------ Propriétés attachées
public static readonly DependencyProperty AllowFullScreenProperty = DependencyProperty.RegisterAttached(
"AllowFullScreen", typeof(bool), typeof(WindowModeAssist), new PropertyMetadata(false, OnAllowFullScreenChanged));
public static bool GetAllowFullScreen(DependencyObject element) => (bool)element.GetValue(AllowFullScreenProperty);
public static void SetAllowFullScreen(DependencyObject element, bool value) => element.SetValue(AllowFullScreenProperty, value);
/// <summary>Mode courant de la fenêtre (liable ; les déclencheurs des styles s'en servent).</summary>
public static readonly DependencyProperty IsFullScreenProperty = DependencyProperty.RegisterAttached(
"IsFullScreen", typeof(bool), typeof(WindowModeAssist),
new FrameworkPropertyMetadata(false, FrameworkPropertyMetadataOptions.BindsTwoWayByDefault, OnIsFullScreenChanged));
public static bool GetIsFullScreen(DependencyObject element) => (bool)element.GetValue(IsFullScreenProperty);
public static void SetIsFullScreen(DependencyObject element, bool value) => element.SetValue(IsFullScreenProperty, value);
/// <summary>Clé sous laquelle le mode de la fenêtre est mémorisé (« main », « admin »…) ; vide : non mémorisé.</summary>
public static readonly DependencyProperty PersistenceKeyProperty = DependencyProperty.RegisterAttached(
"PersistenceKey", typeof(string), typeof(WindowModeAssist), new PropertyMetadata(null));
public static string? GetPersistenceKey(DependencyObject element) => (string?)element.GetValue(PersistenceKeyProperty);
public static void SetPersistenceKey(DependencyObject element, string? value) => element.SetValue(PersistenceKeyProperty, value);
// État interne d'une fenêtre inscrite.
private static readonly DependencyProperty StateProperty = DependencyProperty.RegisterAttached(
"State", typeof(WindowModeState), typeof(WindowModeAssist), new PropertyMetadata(null));
private sealed class WindowModeState
{
public readonly Dictionary<object, object> WindowedChrome = new();
public bool Syncing;
}
// ------------------------------------------------------------------ API pour le code
/// <summary>Bascule le mode d'une fenêtre inscrite (sans effet sinon).</summary>
public static void Toggle(Window window)
{
if (GetAllowFullScreen(window)) SetIsFullScreen(window, !GetIsFullScreen(window));
}
/// <summary>
/// Réaffiche une fenêtre réduite dans SON mode (plein écran ou fenêtré) : à utiliser à la place de
/// <c>WindowState = Normal</c>, qui ferait quitter le plein écran.
/// </summary>
public static void RestoreFromMinimized(Window window)
{
if (window.WindowState != WindowState.Minimized) return;
window.WindowState = GetAllowFullScreen(window) && GetIsFullScreen(window) ? WindowState.Maximized : WindowState.Normal;
}
/// <summary>Déplacement par la barre de titre : seulement en mode fenêtré (en plein écran, la fenêtre reste en place).</summary>
public static void DragMove(Window window)
{
if (window.WindowState != WindowState.Normal) return;
try
{
window.DragMove();
}
catch (InvalidOperationException)
{
// bouton de la souris déjà relâché
}
}
/// <summary>Zone utile (hors barre des tâches) de l'écran où se trouve l'élément, en pixels indépendants (DIP).</summary>
public static Rect WorkArea(Visual? reference)
{
if (reference is not null && PresentationSource.FromVisual(reference) is HwndSource { Handle: var handle } source &&
handle != IntPtr.Zero && source.CompositionTarget is { } target)
{
var monitor = MonitorFromWindow(handle, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor != IntPtr.Zero && GetMonitorInfo(monitor, ref info))
{
var fromDevice = target.TransformFromDevice;
var topLeft = fromDevice.Transform(new Point(info.Work.Left, info.Work.Top));
var bottomRight = fromDevice.Transform(new Point(info.Work.Right, info.Work.Bottom));
return new Rect(topLeft, bottomRight);
}
}
return SystemParameters.WorkArea;
}
// ------------------------------------------------------------------ Mise en place
private static void OnAllowFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window) return;
if (e.NewValue is true)
{
if (window.GetValue(StateProperty) is not null) return;
window.SetValue(StateProperty, new WindowModeState());
window.CommandBindings.Add(new CommandBinding(ToggleFullScreenCommand, (_, args) =>
{
Toggle(window);
args.Handled = true;
}, (_, args) =>
{
args.CanExecute = true;
args.Handled = true;
}));
window.InputBindings.Add(new KeyBinding(ToggleFullScreenCommand, Key.F11, ModifierKeys.None));
window.SourceInitialized += OnSourceInitialized;
window.StateChanged += OnStateChanged;
}
else
{
SetIsFullScreen(window, false);
}
}
/// <summary>Juste avant le premier affichage : taille adaptée à l'écran, puis mode mémorisé.</summary>
private static void OnSourceInitialized(object? sender, EventArgs e)
{
if (sender is not Window window) return;
// Une fenêtre sans bordure agrandie couvrirait par défaut tout l'écran, barre des tâches comprise :
// sa taille agrandie est ramenée à la zone utile de son écran (WM_GETMINMAXINFO).
if (PresentationSource.FromVisual(window) is HwndSource source) source.AddHook(LimitMaximizedToWorkArea);
FitToWorkArea(window);
if (GetPersistenceKey(window) is { Length: > 0 } key && Store?.Get(key) == true) SetIsFullScreen(window, true);
}
/// <summary>
/// Mode fenêtré : jamais plus grande que la zone utile de son écran, et entièrement visible. Une fenêtre placée
/// volontairement hors des écrans (captures de tools\UiSnapshot) n'est pas déplacée.
/// </summary>
private static void FitToWorkArea(Window window)
{
var work = WorkArea(window);
if (work.Width < 1 || work.Height < 1) return;
if (window.Width > work.Width) window.Width = work.Width;
if (window.Height > work.Height) window.Height = work.Height;
if (double.IsNaN(window.Left) || double.IsNaN(window.Top)) return;
if (!work.IntersectsWith(new Rect(window.Left, window.Top, window.Width, window.Height))) return;
if (window.Left + window.Width > work.Right) window.Left = work.Right - window.Width;
if (window.Top + window.Height > work.Bottom) window.Top = work.Bottom - window.Height;
if (window.Left < work.Left) window.Left = work.Left;
if (window.Top < work.Top) window.Top = work.Top;
}
// ------------------------------------------------------------------ Changement de mode
private static void OnIsFullScreenChanged(DependencyObject element, DependencyPropertyChangedEventArgs e)
{
if (element is not Window window || window.GetValue(StateProperty) is not WindowModeState state) return;
var fullScreen = e.NewValue is true;
if (fullScreen && !GetAllowFullScreen(window))
{
SetIsFullScreen(window, false);
return;
}
ApplyChrome(window, state, fullScreen);
if (!state.Syncing)
{
state.Syncing = true;
try
{
// Fenêtre réduite : le mode s'appliquera à sa réapparition (RestoreFromMinimized).
if (window.WindowState != WindowState.Minimized)
window.WindowState = fullScreen ? WindowState.Maximized : WindowState.Normal;
}
finally
{
state.Syncing = false;
}
}
if (GetPersistenceKey(window) is { Length: > 0 } key) Store?.Set(key, fullScreen);
}
/// <summary>
/// État changé par Windows (Win+Flèche bas, réduction depuis la barre des tâches…) : le mode suit. Une fenêtre réduite
/// garde son mode ; une fenêtre agrandie n'existe qu'en plein écran (pas d'agrandissement partiel).
/// </summary>
private static void OnStateChanged(object? sender, EventArgs e)
{
if (sender is not Window window || window.GetValue(StateProperty) is not WindowModeState state || state.Syncing) return;
if (window.WindowState == WindowState.Minimized) return;
var fullScreen = window.WindowState == WindowState.Maximized;
if (fullScreen == GetIsFullScreen(window)) return;
state.Syncing = true;
try
{
SetIsFullScreen(window, fullScreen);
}
finally
{
state.Syncing = false;
}
}
/// <summary>Ressources « WindowChrome.* » de la fenêtre : 0 en plein écran, valeurs d'origine en mode fenêtré.</summary>
private static void ApplyChrome(Window window, WindowModeState state, bool fullScreen)
{
if (fullScreen)
{
foreach (var key in window.Resources.Keys.OfType<string>().Where(k => k.StartsWith(ChromeResourcePrefix, StringComparison.Ordinal)).ToList())
{
var value = window.Resources[key];
object? flat = value switch
{
CornerRadius => new CornerRadius(0),
Thickness => new Thickness(0),
_ => null
};
if (flat is null) continue;
state.WindowedChrome.TryAdd(key, value);
window.Resources[key] = flat;
}
}
else
{
foreach (var (key, value) in state.WindowedChrome) window.Resources[key] = value;
state.WindowedChrome.Clear();
}
}
// ------------------------------------------------------------------ Agrandie = zone utile de l'écran
private const int WmGetMinMaxInfo = 0x0024;
/// <summary>Marge laissée sur le bord d'une barre des tâches masquée automatiquement, pour qu'elle puisse réapparaître.</summary>
private const int AutoHideMargin = 2;
/// <summary>
/// Taille et position de la fenêtre agrandie : la zone utile de l'écran qui la contient (hors barre des tâches et autres
/// barres d'application). Barre des tâches en masquage automatique : un liseré de 2 pixels lui est laissé sur son bord,
/// sinon la souris ne pourrait plus la faire apparaître.
/// </summary>
private static IntPtr LimitMaximizedToWorkArea(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
if (msg != WmGetMinMaxInfo || lParam == IntPtr.Zero) return IntPtr.Zero;
var monitor = MonitorFromWindow(hwnd, MonitorDefaultToNearest);
var info = new MonitorInfo { Size = Marshal.SizeOf<MonitorInfo>() };
if (monitor == IntPtr.Zero || !GetMonitorInfo(monitor, ref info)) return IntPtr.Zero;
var work = info.Work;
if (work.Left == info.Monitor.Left && work.Top == info.Monitor.Top &&
work.Right == info.Monitor.Right && work.Bottom == info.Monitor.Bottom)
{
// Aucune place réservée : barre des tâches absente de cet écran, ou masquée automatiquement.
if (HasAutoHideBar(AppBarEdgeLeft, info.Monitor)) work.Left += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeTop, info.Monitor)) work.Top += AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeRight, info.Monitor)) work.Right -= AutoHideMargin;
else if (HasAutoHideBar(AppBarEdgeBottom, info.Monitor)) work.Bottom -= AutoHideMargin;
}
var minMax = Marshal.PtrToStructure<MinMaxInfo>(lParam);
// Position relative au coin de l'écran (Windows l'applique à l'écran où la fenêtre est agrandie).
minMax.MaxPosition = new NativePoint { X = work.Left - info.Monitor.Left, Y = work.Top - info.Monitor.Top };
minMax.MaxSize = new NativePoint { X = work.Right - work.Left, Y = work.Bottom - work.Top };
Marshal.StructureToPtr(minMax, lParam, false);
handled = true;
return IntPtr.Zero;
}
private static bool HasAutoHideBar(int edge, NativeRect monitor)
{
var data = new AppBarData { Size = Marshal.SizeOf<AppBarData>(), Edge = edge, Rect = monitor };
return SHAppBarMessage(AppBarGetAutoHideBarEx, ref data) != IntPtr.Zero;
}
// ------------------------------------------------------------------ Win32
private const int MonitorDefaultToNearest = 2;
private const int AppBarGetAutoHideBarEx = 0x0000000B;
private const int AppBarEdgeLeft = 0, AppBarEdgeTop = 1, AppBarEdgeRight = 2, AppBarEdgeBottom = 3;
[StructLayout(LayoutKind.Sequential)]
private struct NativePoint
{
public int X, Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct MinMaxInfo
{
public NativePoint Reserved;
public NativePoint MaxSize;
public NativePoint MaxPosition;
public NativePoint MinTrackSize;
public NativePoint MaxTrackSize;
}
[StructLayout(LayoutKind.Sequential)]
private struct AppBarData
{
public int Size;
public IntPtr Window;
public uint CallbackMessage;
public int Edge;
public NativeRect Rect;
public IntPtr Param;
}
[DllImport("shell32.dll")]
private static extern IntPtr SHAppBarMessage(int message, ref AppBarData data);
[StructLayout(LayoutKind.Sequential)]
private struct NativeRect
{
public int Left, Top, Right, Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct MonitorInfo
{
public int Size;
public NativeRect Monitor;
public NativeRect Work;
public uint Flags;
}
[DllImport("user32.dll")]
private static extern IntPtr MonitorFromWindow(IntPtr hwnd, int flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info);
}
+18
View File
@@ -116,3 +116,21 @@ public sealed class NullToVisibilityConverter : IValueConverter
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
/// <summary>
/// Hauteur d'une page « en deux colonnes » du menu d'administration : toute la zone visible (arrondie à l'inférieur, pour
/// ne jamais faire apparaître d'ascenseur inutile), mais jamais moins que le paramètre (pixels) : sur un très petit écran,
/// la page entière défile alors, et rien n'est rogné.
/// </summary>
public sealed class ViewportHeightConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var viewport = value is double d && !double.IsNaN(d) && !double.IsInfinity(d) ? Math.Floor(d) : 0;
var minimum = double.TryParse(parameter as string, NumberStyles.Float, CultureInfo.InvariantCulture, out var m) ? m : 0;
return Math.Max(viewport, minimum);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) =>
throw new NotSupportedException();
}
+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>2.4.1</Version>
<Version>2.5.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -0,0 +1,78 @@
using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
using GameLauncher.Core.Utils;
namespace GameLauncher.Platform;
/// <summary>
/// Mode d'affichage mémorisé de chaque fenêtre qui le permet (plein écran ou fenêtré) :
/// %AppData%\&lt;Publisher&gt;\GameLauncher\window-modes.json, ex. { "main": true, "admin": false }.
/// Simple préférence d'affichage, sans donnée personnelle ; fichier illisible ou modifié à la main : ignoré.
/// </summary>
public sealed partial class WindowModeStore
{
public const string FileName = "window-modes.json";
private const int MaxFileSize = 4 * 1024;
private const int MaxEntries = 32;
private readonly string _path;
private readonly Dictionary<string, bool> _modes;
private readonly object _lock = new();
public WindowModeStore(string path)
{
_path = path;
_modes = Load(path);
}
/// <summary>Plein écran mémorisé pour cette fenêtre (null : jamais choisi).</summary>
public bool? Get(string key)
{
lock (_lock) return _modes.TryGetValue(key, out var fullScreen) ? fullScreen : null;
}
public void Set(string key, bool fullScreen)
{
if (!IsValidKey(key)) return;
Dictionary<string, bool> snapshot;
lock (_lock)
{
if (_modes.TryGetValue(key, out var current) && current == fullScreen) return;
if (!_modes.ContainsKey(key) && _modes.Count >= MaxEntries) return;
_modes[key] = fullScreen;
snapshot = new Dictionary<string, bool>(_modes);
}
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(_path))!);
FileUtil.WriteAllTextAtomic(_path, JsonSerializer.Serialize(snapshot, JsonDefaults.Options));
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
Log.Warn($"Mode d'affichage non mémorisé ({_path}) : {ex.Message}");
}
}
private static Dictionary<string, bool> Load(string path)
{
var modes = new Dictionary<string, bool>(StringComparer.Ordinal);
try
{
if (!File.Exists(path) || new FileInfo(path).Length > MaxFileSize) return modes;
var loaded = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(path), JsonDefaults.Options);
foreach (var (key, value) in loaded ?? new Dictionary<string, bool>())
if (IsValidKey(key) && modes.Count < MaxEntries) modes[key] = value;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
Log.Warn($"Modes d'affichage illisibles ({path}) : {ex.Message}");
}
return modes;
}
private static bool IsValidKey(string key) => KeyPattern().IsMatch(key);
[GeneratedRegex("^[a-z0-9][a-z0-9.-]{0,31}$")]
private static partial Regex KeyPattern();
}
+1 -1
View File
@@ -40,7 +40,7 @@ public sealed class WindowService : IWindowService
public void Restore()
{
if (!_window.IsVisible) _window.Show(); // l'icône de la zone de notification disparaît (voir App)
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
}
@@ -61,7 +61,7 @@ public sealed class AdminWindowService : IDisposable
{
if (_window is not null)
{
if (_window.WindowState == WindowState.Minimized) _window.WindowState = WindowState.Normal;
Controls.WindowModeAssist.RestoreFromMinimized(_window); // dans son mode : plein écran ou fenêtré
_window.Activate();
return;
}
+108 -105
View File
@@ -216,13 +216,14 @@
<ControlTemplate TargetType="ListBoxItem">
<Grid Background="Transparent">
<Border x:Name="Bd" CornerRadius="12" Background="Transparent" Padding="12,10">
<StackPanel Orientation="Horizontal">
<Path x:Name="Icon" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Center"
<DockPanel>
<Path x:Name="Icon" DockPanel.Dock="Left" Width="18" Height="18" Stretch="Uniform" VerticalAlignment="Center"
Fill="{DynamicResource TextSecondaryBrush}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" />
<TextBlock x:Name="Label" Text="{Binding Title}" Margin="12,0,0,0" VerticalAlignment="Center"
FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
FontSize="13" FontWeight="SemiBold" Foreground="{DynamicResource TextSecondaryBrush}"
TextTrimming="CharacterEllipsis" />
</DockPanel>
</Border>
<Border x:Name="Indicator" Width="4" Height="20" CornerRadius="2" HorizontalAlignment="Left"
Margin="-2,0,0,0" Background="{DynamicResource AccentGradientBrush}" Opacity="0" />
@@ -359,10 +360,10 @@
<StackPanel>
<!-- Erreur de chargement -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Compte connecté -->
@@ -376,12 +377,13 @@
<TextBlock Text="{Binding Initials}" Style="{StaticResource AdminAvatarTextStyle}" FontSize="17" />
</Border>
<StackPanel Grid.Column="1" Margin="16,0,0,0">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding DisplayName}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" />
<Border Style="{StaticResource AdminChipStyle}" Margin="12,0,0,0" Padding="9,2" VerticalAlignment="Center">
<WrapPanel>
<TextBlock Text="{Binding DisplayName}" FontSize="17" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,12,0"
TextTrimming="CharacterEllipsis" />
<Border Style="{StaticResource AdminChipStyle}" Margin="0" Padding="9,2" VerticalAlignment="Center">
<TextBlock Text="{Binding RoleText}" FontSize="11" FontWeight="SemiBold" Foreground="{DynamicResource Accent2Brush}" />
</Border>
</StackPanel>
</WrapPanel>
<TextBlock Text="{Binding KeyText}" Style="{StaticResource AdminHintStyle}" Margin="0,4,0,0" />
<TextBlock Text="{Binding SessionText}" Style="{StaticResource AdminHintStyle}" Margin="0,2,0,0" />
<Button Style="{StaticResource LinkButtonStyle}" Content="Mes appareils" HorizontalAlignment="Left" Margin="0,6,0,12"
@@ -492,7 +494,8 @@
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
<Run Text="{Binding GameId, Mode=OneWay}" Foreground="{DynamicResource TextMutedBrush}" />
</TextBlock>
<TextBlock Grid.Column="1" Text="{Binding ManifestHost}" FontSize="12" Margin="12,0,0,0"
<TextBlock Grid.Column="1" Text="{Binding ManifestHost}" FontSize="12" Margin="12,0,0,0" MaxWidth="320"
TextTrimming="CharacterEllipsis" ToolTip="{Binding ManifestHost}"
Foreground="{DynamicResource TextMutedBrush}" />
</Grid>
</DataTemplate>
@@ -567,21 +570,21 @@
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<!-- Erreur (chargement, publication refusée par le serveur…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Publication non configurée sur le serveur -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620"
<TextBlock Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5"
Text="Publication désactivée : le fichier public du catalogue n'est pas configuré sur le serveur (LauncherApi:Catalog:PublicFile). Les modifications peuvent être préparées mais pas publiées." />
</StackPanel>
</DockPanel>
</Border>
<!-- Publié entre-temps par un autre administrateur -->
@@ -600,11 +603,11 @@
<!-- Résultat de la dernière publication -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
</StackPanel>
@@ -984,7 +987,7 @@
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource Accent2Brush}" Data="{StaticResource Icon.Gamepad}" />
</Border>
<TextBlock DockPanel.Dock="Left" Text="Jeu" Style="{StaticResource AdminFieldLabelStyle}" Margin="0,0,12,0" VerticalAlignment="Center" />
<ComboBox Style="{StaticResource ModernComboBoxStyle}" Width="360" HorizontalAlignment="Left"
<ComboBox Style="{StaticResource ModernComboBoxStyle}" MaxWidth="360" HorizontalAlignment="Stretch"
ItemsSource="{Binding GameChoices}" SelectedItem="{Binding SelectedGame, Mode=TwoWay}" DisplayMemberPath="Label"
IsEnabled="{Binding IsGameSwitchEnabled}" ToolTip="Changer de jeu" />
</DockPanel>
@@ -998,10 +1001,10 @@
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<!-- Erreur (chargement, jeu non configuré sur le serveur, refus…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Manifest modifié entre-temps -->
@@ -1020,11 +1023,11 @@
<!-- Résultat du dernier enregistrement -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Jeu : à choisir avant tout (plusieurs jeux), puis liste pour en changer -->
@@ -1227,21 +1230,21 @@
<StackPanel>
<!-- Erreur (contrôle du dossier, refus du serveur…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Version publiée -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Envoi interrompu : reprise ou abandon -->
@@ -1277,16 +1280,16 @@
<TextBlock Text="Dossier de publication" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding PublishedText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<!-- Signature de la version en ligne (phase 7) -->
<StackPanel Orientation="Horizontal" Margin="0,4,0,0"
<DockPanel Margin="0,4,0,0"
Visibility="{Binding HasOnlineSignatureText, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding IsOnlineSigned, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding OnlineSignatureText}" Style="{StaticResource AdminHintStyle}" Margin="8,0,0,0" />
</DockPanel>
<DockPanel Margin="0,12,0,0">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Margin="12,0,0,0">
<Button Style="{StaticResource IconButtonStyle}" Width="30" Height="30" Padding="8" Margin="0,0,6,0"
@@ -1348,11 +1351,11 @@
Visibility="{Binding HasWarnings, Converter={StaticResource BoolToVisibility}}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal" Margin="0,4,0,0">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
<DockPanel Margin="0,4,0,0">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Alert}"
VerticalAlignment="Top" Margin="0,2,0,0" />
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding}" Margin="8,0,0,0" FontSize="12.5" TextWrapping="Wrap" />
</DockPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
@@ -1484,19 +1487,19 @@
<DataTemplate DataType="{x:Type aacc:MyDevicesPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
@@ -1600,19 +1603,19 @@
<DataTemplate DataType="{x:Type aacc:AdminsPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<ContentControl Content="{Binding CreatedInvitation}" Focusable="False"
@@ -1747,11 +1750,11 @@
<!-- Groupe système, ou votre propre groupe -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding HasNotice, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Lock}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource WarningBrush}" Data="{StaticResource Icon.Lock}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding Notice}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="560" />
</StackPanel>
<TextBlock Text="{Binding Notice}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<TextBlock Text="Nom du groupe" Style="{StaticResource AdminFieldLabelStyle}" />
@@ -1842,19 +1845,19 @@
<DockPanel>
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,0">
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
</StackPanel>
@@ -1986,11 +1989,11 @@
<DataTemplate DataType="{x:Type aaud:AuditPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Filtres -->
@@ -2004,7 +2007,7 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel>
<TextBlock Text="Rechercher (nom, adresse IP, clé, version…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBlock Text="Rechercher (nom, appareil, adresse IP, clé…)" Style="{StaticResource AdminFieldLabelStyle}" />
<TextBox Style="{StaticResource ModernTextBoxStyle}" MaxLength="100"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged, Delay=250}" />
</StackPanel>
@@ -2142,19 +2145,19 @@
<DataTemplate DataType="{x:Type asig:SigningPanelViewModel}">
<StackPanel>
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource WarningBrush}"
Visibility="{Binding ShowPublishingDisabled, Converter={StaticResource BoolToVisibility}}">
@@ -2175,14 +2178,14 @@
<StackPanel Grid.Column="1" Margin="14,0,0,0">
<TextBlock Text="Trousseau publié" Style="{StaticResource AdminCardTitleStyle}" />
<TextBlock Text="{Binding KeyringText}" Style="{StaticResource AdminHintStyle}" Margin="0,3,0,0" />
<StackPanel Orientation="Horizontal" Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
<DockPanel Margin="0,6,0,0" Visibility="{Binding HasKeyring, Converter={StaticResource BoolToVisibility}}">
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.ShieldCheck}"
VerticalAlignment="Top" Margin="0,1,0,0" Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}}" />
<Path Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<Path DockPanel.Dock="Left" Width="13" Height="13" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" Margin="0,1,0,0"
Visibility="{Binding RootValid, Converter={StaticResource BoolToVisibility}, ConverterParameter=Invert}" />
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" MaxWidth="600" />
</StackPanel>
<TextBlock Text="{Binding RootText}" FontSize="12" Margin="8,0,0,0" TextWrapping="Wrap" />
</DockPanel>
<TextBlock FontSize="12" Margin="0,8,0,0" TextWrapping="Wrap" Foreground="{DynamicResource TextSecondaryBrush}"
Visibility="{Binding KeyringUrl, Converter={StaticResource StringToVisibility}}">
<Run Text="Adresse à mettre dans « keyringUrl » de launcher.json :" />
@@ -2321,21 +2324,21 @@
<StackPanel>
<!-- Erreur (refus du serveur : fichier utilisé, droits du dossier…) -->
<Border Style="{StaticResource AdminErrorBannerStyle}" Visibility="{Binding HasError, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="16" Height="16" Stretch="Uniform" Fill="{DynamicResource DangerBrush}" Data="{StaticResource Icon.AlertCircle}"
VerticalAlignment="Top" />
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding ErrorMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Résultat de la dernière suppression -->
<Border Style="{StaticResource AdminInfoBannerStyle}" BorderBrush="{DynamicResource SuccessBrush}"
Visibility="{Binding HasStatus, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
<DockPanel>
<Path DockPanel.Dock="Left" Width="15" Height="15" Stretch="Uniform" Fill="{DynamicResource SuccessBrush}" Data="{StaticResource Icon.CheckCircle}"
VerticalAlignment="Top" Margin="0,1,0,0" />
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" MaxWidth="620" />
</StackPanel>
<TextBlock Text="{Binding StatusMessage}" Margin="10,0,0,0" TextWrapping="Wrap" FontSize="12.5" />
</DockPanel>
</Border>
<!-- Espace disque -->
+21
View File
@@ -12,6 +12,7 @@
<conv:StringToVisibilityConverter x:Key="StringToVisibility" />
<conv:IconKeyConverter x:Key="IconKey" />
<conv:KindToResourceConverter x:Key="KindToResource" />
<conv:ViewportHeightConverter x:Key="ViewportHeight" />
<!-- ============================================================ Focus clavier discret -->
<Style x:Key="FocusVisualStyle">
@@ -69,6 +70,26 @@
<Setter Property="ui:IconAssist.HoverBrush" Value="{DynamicResource DangerBrush}" />
</Style>
<!--
Plein écran / mode fenêtré (F11), pour toute fenêtre inscrite avec ui:WindowModeAssist.AllowFullScreen="True" :
icône et infobulle suivent le mode ; bouton masqué dans une fenêtre qui ne le permet pas.
Usage : <Button Style="{StaticResource WindowModeButtonStyle}" />, entre « Réduire » et « Fermer ».
-->
<Style x:Key="WindowModeButtonStyle" TargetType="Button" BasedOn="{StaticResource IconButtonStyle}">
<Setter Property="Command" Value="{x:Static ui:WindowModeAssist.ToggleFullScreenCommand}" />
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.Fullscreen}" />
<Setter Property="ToolTip" Value="Plein écran (F11)" />
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="ui:IconAssist.Icon" Value="{StaticResource Icon.FullscreenExit}" />
<Setter Property="ToolTip" Value="Quitter le plein écran (F11)" />
</DataTrigger>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.AllowFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="False">
<Setter Property="Visibility" Value="Collapsed" />
</DataTrigger>
</Style.Triggers>
</Style>
<!-- ============================================================ Bouton principal (JOUER / INSTALLER…) -->
<Style x:Key="PrimaryButtonStyle" TargetType="Button">
<Setter Property="Height" Value="60" />
+3
View File
@@ -17,6 +17,9 @@
<Geometry x:Key="Icon.Cog">M12,15.5A3.5,3.5 0 0,1 8.5,12A3.5,3.5 0 0,1 12,8.5A3.5,3.5 0 0,1 15.5,12A3.5,3.5 0 0,1 12,15.5M19.43,12.97C19.47,12.65 19.5,12.33 19.5,12C19.5,11.67 19.47,11.34 19.43,11L21.54,9.37C21.73,9.22 21.78,8.95 21.66,8.73L19.66,5.27C19.54,5.05 19.27,4.96 19.05,5.05L16.56,6.05C16.04,5.66 15.5,5.32 14.87,5.07L14.5,2.42C14.46,2.18 14.25,2 14,2H10C9.75,2 9.54,2.18 9.5,2.42L9.13,5.07C8.5,5.32 7.96,5.66 7.44,6.05L4.95,5.05C4.73,4.96 4.46,5.05 4.34,5.27L2.34,8.73C2.21,8.95 2.27,9.22 2.46,9.37L4.57,11C4.53,11.34 4.5,11.67 4.5,12C4.5,12.33 4.53,12.65 4.57,12.97L2.46,14.63C2.27,14.78 2.21,15.05 2.34,15.27L4.34,18.73C4.46,18.95 4.73,19.03 4.95,18.95L7.44,17.94C7.96,18.34 8.5,18.68 9.13,18.93L9.5,21.58C9.54,21.82 9.75,22 10,22H14C14.25,22 14.46,21.82 14.5,21.58L14.87,18.93C15.5,18.67 16.04,18.34 16.56,17.94L19.05,18.95C19.27,19.03 19.54,18.95 19.66,18.73L21.66,15.27C21.78,15.05 21.73,14.78 21.54,14.63L19.43,12.97Z</Geometry>
<Geometry x:Key="Icon.Close">M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z</Geometry>
<Geometry x:Key="Icon.Minimize">M20,14H4V10H20</Geometry>
<!-- Plein écran (F11) / quitter le plein écran : bouton des fenêtres qui le permettent (WindowModeAssist) -->
<Geometry x:Key="Icon.Fullscreen">M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z</Geometry>
<Geometry x:Key="Icon.FullscreenExit">M14,14H19V16H16V19H14V14M5,14H10V19H8V16H5V14M8,5H10V10H5V8H8V5M19,8V10H14V5H16V8H19Z</Geometry>
<Geometry x:Key="Icon.WindowRestore">M4,8H8V4H20V16H16V20H4V8M16,8V14H18V6H10V8H16M6,12V18H14V12H6Z</Geometry>
<Geometry x:Key="Icon.ExitToApp">M19,3H5C3.89,3 3,3.89 3,5V9H5V5H19V19H5V15H3V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M10.08,15.58L11.5,17L16.5,12L11.5,7L10.08,8.41L12.67,11H3V13H12.67L10.08,15.58Z</Geometry>
<Geometry x:Key="Icon.Folder">M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z</Geometry>
+57 -16
View File
@@ -17,7 +17,9 @@
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
ui:WheelAssist.IsEnabled="True">
ui:WheelAssist.IsEnabled="True"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="admin">
<!--
Menu d'administration (accès discret : Ctrl+Maj+Alt+A, ou 5 clics rapides sur le logo).
@@ -27,6 +29,29 @@
• AdminWorkspaceViewModel → navigation entre les pages des modules + page affichée.
-->
<Window.Resources>
<!--
Mode fenêtré : ombre et coins arrondis. En plein écran (F11, bouton de la barre de titre), WindowModeAssist met ces
ressources « WindowChrome.* » à 0. Toute page d'un module (présente ou future) s'affiche dans cette fenêtre et
profite donc du plein écran sans rien déclarer.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">20</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">20</CornerRadius>
<CornerRadius x:Key="WindowChrome.NavRadius">0,0,0,20</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<DataTemplate DataType="{x:Type avm:LoginViewModel}">
<local:LoginView />
</DataTemplate>
@@ -44,7 +69,7 @@
</Grid.ColumnDefinitions>
<!-- Navigation -->
<Border Background="{DynamicResource RailBrush}" CornerRadius="0,0,0,20"
<Border Background="{DynamicResource RailBrush}" CornerRadius="{DynamicResource WindowChrome.NavRadius}"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0">
<DockPanel Margin="16,20,16,18">
<!-- Compte connecté -->
@@ -93,7 +118,7 @@
<!-- Page affichée -->
<DockPanel Grid.Column="1" Margin="32,22,18,18">
<StackPanel DockPanel.Dock="Top" Margin="0,0,14,18">
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" />
<TextBlock Text="{Binding PanelTitle}" FontSize="22" FontWeight="SemiBold" TextTrimming="CharacterEllipsis" />
<TextBlock Text="{Binding PanelDescription}" FontSize="12.5" Margin="0,4,0,0" TextWrapping="Wrap"
Foreground="{DynamicResource TextSecondaryBrush}" />
</StackPanel>
@@ -103,41 +128,54 @@
Content="{Binding SelectedPanel.ViewModel}"
ContentTemplateSelector="{StaticResource AdminFooterSelector}" />
<!-- Retour en haut : page changée, ou demandé par la page (après « Recharger », « Publier »…).
Page en deux colonnes (HasSplitLayout) : la fenêtre ne fait plus défiler la page entière ; la page reçoit
la hauteur disponible et chaque colonne a son propre ascenseur (Themes/Admin.xaml). -->
<ScrollViewer Focusable="False"
Page en deux colonnes (HasSplitLayout) : la page reçoit la hauteur visible et chaque colonne a son propre
ascenseur (Themes/Admin.xaml) ; sur un très petit écran (moins de 360 px de haut), la page entière défile
en plus, pour que rien ne soit jamais rogné (bandeaux en haut compris). -->
<ScrollViewer Focusable="False" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled"
ui:ScrollAssist.ResetOn="{Binding SelectedPanel}"
ui:ScrollAssist.ScrollToTopToken="{Binding SelectedPanel.ViewModel.ScrollToTopToken}">
<ScrollViewer.Style>
<Style TargetType="ScrollViewer">
<Setter Property="VerticalScrollBarVisibility" Value="Auto" />
<Setter Property="Padding" Value="0,0,14,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="VerticalScrollBarVisibility" Value="Disabled" />
<Setter Property="Padding" Value="0" />
</DataTrigger>
</Style.Triggers>
</Style>
</ScrollViewer.Style>
<!-- Modèle choisi selon le type de la page (Themes/Admin.xaml) -->
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False" />
<ContentControl Content="{Binding SelectedPanel.ViewModel}" Focusable="False">
<ContentControl.Style>
<Style TargetType="ContentControl">
<Style.Triggers>
<DataTrigger Binding="{Binding SelectedPanel.ViewModel.HasSplitLayout}" Value="True">
<Setter Property="Height"
Value="{Binding ViewportHeight, RelativeSource={RelativeSource AncestorType=ScrollViewer}, Converter={StaticResource ViewportHeight}, ConverterParameter=360}" />
</DataTrigger>
</Style.Triggers>
</Style>
</ContentControl.Style>
</ContentControl>
</ScrollViewer>
</DockPanel>
</Grid>
</DataTemplate>
</Window.Resources>
<Grid Margin="20">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<!-- Ombre portée -->
<Border CornerRadius="20" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="32" ShadowDepth="8" Direction="270" Opacity="0.6" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<Border CornerRadius="20" Background="{DynamicResource SurfaceSolidBrush}"
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource SurfaceSolidBrush}"
BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1">
<!--
Responsive (ScaleToFitDecorator) : 1038 × 678 en mode fenêtré. Plus grand (plein écran) : les pages s'étalent.
Plus petit : jusqu'à 960 × 560 la mise en page s'adapte (les pages défilent) ; en dessous (petit écran, mise à
l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator DesignWidth="960" DesignHeight="560">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="64" />
@@ -172,6 +210,8 @@
</Border>
<Button Style="{StaticResource IconButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
ToolTip="Réduire" Click="OnMinimize" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="9" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ui:IconAssist.Icon="{StaticResource Icon.Close}"
ToolTip="Fermer (déconnexion)" Margin="4,0,0,0" Click="OnClose" />
</StackPanel>
@@ -181,6 +221,7 @@
<!-- Écran courant -->
<ContentControl Grid.Row="1" Content="{Binding Screen}" Focusable="False" />
</Grid>
</ui:ScaleToFitDecorator>
</Border>
</Grid>
</Window>
@@ -29,7 +29,7 @@ public partial class AdminWindow : Window
PreviewMouseDown += (_, _) => _shell.NotifyActivity();
PreviewMouseWheel += (_, _) => _shell.NotifyActivity();
PreviewKeyDown += OnPreviewKeyDown;
Loaded += (_, _) => FitToScreen();
// Taille adaptée à l'écran (mode fenêtré) et plein écran : ui:WindowModeAssist (AdminWindow.xaml).
// Session Windows verrouillée (Win+L, mise en veille) : l'administration se verrouille aussi.
SystemEvents.SessionSwitch += OnSessionSwitch;
@@ -54,16 +54,6 @@ public partial class AdminWindow : Window
_shell.NotifyActivity();
}
/// <summary>Jamais plus haute que la zone utile de l'écran (petits écrans, mise à l'échelle 125-150 %).</summary>
private void FitToScreen()
{
var work = SystemParameters.WorkArea;
if (Height > work.Height) Height = work.Height;
if (Width > work.Width) Width = work.Width;
if (Top < work.Top) Top = work.Top;
if (Left < work.Left) Left = work.Left;
}
private void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
_shell.NotifyActivity();
@@ -87,10 +77,11 @@ public partial class AdminWindow : Window
});
}
/// <summary>Déplacement par la barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
private void OnMinimize(object sender, RoutedEventArgs e) => WindowState = WindowState.Minimized;
@@ -13,14 +13,17 @@ public partial class ChangelogWindow : Window
InitializeComponent();
SubtitleText.Text = string.IsNullOrWhiteSpace(gameName) ? "Nouveautés et correctifs" : $"{gameName} · nouveautés et correctifs";
Entries.ItemsSource = entries;
FitToScreen();
SourceInitialized += (_, _) => FitToScreen();
}
/// <summary>La fenêtre ne dépasse jamais la zone utile de l'écran : au-delà, les notes défilent.</summary>
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (celle-ci peut être en plein écran sur
/// un autre écran que l'écran principal) : au-delà, les notes défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 250; // en-tête, bouton, marges et ombre
EntriesScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
EntriesScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnClose(object sender, RoutedEventArgs e) => Close();
+52 -18
View File
@@ -14,9 +14,34 @@
FontFamily="{DynamicResource UiFont}"
Foreground="{DynamicResource TextPrimaryBrush}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal">
TextOptions.TextFormattingMode="Ideal"
ui:WindowModeAssist.AllowFullScreen="True"
ui:WindowModeAssist.PersistenceKey="main">
<Window.Resources>
<!--
Mode fenêtré : ombre autour de la fenêtre et coins arrondis. En plein écran (F11, bouton de la barre de titre),
WindowModeAssist met ces ressources « WindowChrome.* » à 0 : la fenêtre occupe toute la zone utile de l'écran
(barre des tâches visible), sans marge ni arrondi.
-->
<Thickness x:Key="WindowChrome.ShadowMargin">16</Thickness>
<CornerRadius x:Key="WindowChrome.Radius">22</CornerRadius>
<CornerRadius x:Key="WindowChrome.RailRadius">22,0,0,22</CornerRadius>
<!-- Ombre portée : inutile (et coûteuse) en plein écran, où elle serait hors de l'écran -->
<Style x:Key="WindowShadowStyle" TargetType="Border">
<Setter Property="Effect">
<Setter.Value>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Setter.Value>
</Setter>
<Style.Triggers>
<DataTrigger Binding="{Binding Path=(ui:WindowModeAssist.IsFullScreen), RelativeSource={RelativeSource AncestorType=Window}}" Value="True">
<Setter Property="Effect" Value="{x:Null}" />
</DataTrigger>
</Style.Triggers>
</Style>
<Style x:Key="ChipStyle" TargetType="Border">
<Setter Property="CornerRadius" Value="16" />
<Setter Property="Background" Value="{DynamicResource ChipBrush}" />
@@ -166,24 +191,21 @@
</Style>
</Window.Resources>
<Grid Margin="16">
<Grid Margin="{DynamicResource WindowChrome.ShadowMargin}">
<Grid x:Name="Root">
<!-- ===================================================== Fond (thème) : ombre, image, lueur, dégradés -->
<Border CornerRadius="22" Background="{DynamicResource BackgroundBrush}">
<Border.Effect>
<DropShadowEffect BlurRadius="28" ShadowDepth="0" Opacity="0.7" Color="Black" />
</Border.Effect>
</Border>
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource BackgroundBrush}"
Style="{StaticResource WindowShadowStyle}" />
<!-- Illustration du thème (Default : Assets/background.png, Exovival : Assets/Themes/exovival-background.jpg) -->
<Border x:Name="BackgroundLayer" CornerRadius="22">
<Border x:Name="BackgroundLayer" CornerRadius="{DynamicResource WindowChrome.Radius}">
<Border.Background>
<ImageBrush ImageSource="{DynamicResource BackgroundImage}" Stretch="UniformToFill" AlignmentX="Right" />
</Border.Background>
</Border>
<Border CornerRadius="22" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="22" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="22" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="22" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource AmbientGlowBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource HeroOverlayBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" Background="{DynamicResource VignetteBrush}" />
<Border CornerRadius="{DynamicResource WindowChrome.Radius}" BorderBrush="{DynamicResource WindowBorderBrush}" BorderThickness="1" />
<Grid>
<Grid.ColumnDefinitions>
@@ -192,7 +214,7 @@
</Grid.ColumnDefinitions>
<!-- ===================================================== Liste des jeux (si plusieurs jeux) -->
<Border Width="88" CornerRadius="22,0,0,22" Background="{DynamicResource RailBrush}"
<Border Width="88" CornerRadius="{DynamicResource WindowChrome.RailRadius}" Background="{DynamicResource RailBrush}"
MouseLeftButtonDown="OnTitleBarMouseDown"
BorderBrush="{DynamicResource BorderBrush}" BorderThickness="0,0,1,0"
Visibility="{Binding IsMultiGame, Converter={StaticResource BoolToVisibility}}">
@@ -208,7 +230,13 @@
</DockPanel>
</Border>
<Grid Grid.Column="1" DataContext="{Binding SelectedGame}">
<!--
Responsive (ScaleToFitDecorator) : zone du jeu de 1168 × 708 en mode fenêtré. Plus grand (plein écran) : le contenu
s'étale (colonnes fluides). Plus petit : jusqu'à 1100 × 620 la mise en page s'adapte (la présentation et les notes
défilent) ; en dessous (petit écran, mise à l'échelle 125-150 %), tout est réduit d'un bloc, sans rien rogner.
-->
<ui:ScaleToFitDecorator Grid.Column="1" DesignWidth="1100" DesignHeight="620">
<Grid DataContext="{Binding SelectedGame}">
<Grid.RowDefinitions>
<RowDefinition Height="76" />
<RowDefinition Height="*" />
@@ -255,6 +283,8 @@
<Button Style="{StaticResource IconButtonStyle}" ToolTip="Réduire" Padding="12"
ui:IconAssist.Icon="{StaticResource Icon.Minimize}"
Command="{Binding DataContext.MinimizeCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
<!-- Plein écran / mode fenêtré (F11) -->
<Button Style="{StaticResource WindowModeButtonStyle}" Padding="11" Margin="4,0,0,0" />
<Button Style="{StaticResource CloseButtonStyle}" ToolTip="{Binding DataContext.CloseToolTip, RelativeSource={RelativeSource AncestorType=Window}}" Padding="12" Margin="4,0,0,0"
ui:IconAssist.Icon="{StaticResource Icon.Close}"
Command="{Binding DataContext.CloseCommand, RelativeSource={RelativeSource AncestorType=Window}}" />
@@ -265,11 +295,13 @@
<Grid Grid.Row="1" Margin="58,6,34,20">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="370" />
<!-- Notes de version : 370 en mode fenêtré, plus larges en plein écran (jusqu'à 560) -->
<ColumnDefinition Width="0.5*" MinWidth="370" MaxWidth="560" />
</Grid.ColumnDefinitions>
<!-- Présentation du jeu -->
<StackPanel VerticalAlignment="Center" Margin="0,0,40,0">
<!-- Présentation du jeu : défile si elle ne tient pas en hauteur (description, liens et bandeau à la fois) -->
<ScrollViewer Margin="0,0,24,0" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled" Focusable="False">
<StackPanel VerticalAlignment="Center" Margin="0,0,16,0">
<!-- Badge d'état -->
<Border HorizontalAlignment="Left" CornerRadius="14" Padding="12,6"
Background="{DynamicResource BadgeBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1">
@@ -404,6 +436,7 @@
</Grid>
</Border>
</StackPanel>
</ScrollViewer>
<!-- Notes de version -->
<Border Grid.Column="1" Margin="0,4" Padding="22,20,10,18" CornerRadius="20"
@@ -537,6 +570,7 @@
</Grid>
</Border>
</Grid>
</ui:ScaleToFitDecorator>
</Grid>
</Grid>
@@ -544,6 +578,6 @@
<Image x:Name="TransitionImage" IsHitTestVisible="False" Visibility="Collapsed" Stretch="Fill" />
<!-- Voile sombre affiché derrière les boîtes de dialogue -->
<Border x:Name="DimOverlay" CornerRadius="22" Background="#A6000000" Visibility="Collapsed" />
<Border x:Name="DimOverlay" CornerRadius="{DynamicResource WindowChrome.Radius}" Background="#A6000000" Visibility="Collapsed" />
</Grid>
</Window>
+2 -2
View File
@@ -54,11 +54,11 @@ public partial class MainWindow : Window, IThemeTransition
AdminRequested?.Invoke();
}
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre.</summary>
/// <summary>Déplacement de la fenêtre sans bordure par sa barre de titre (mode fenêtré seulement).</summary>
private void OnTitleBarMouseDown(object sender, MouseButtonEventArgs e)
{
if (e.ChangedButton == MouseButton.Left && e.ButtonState == MouseButtonState.Pressed)
DragMove();
Controls.WindowModeAssist.DragMove(this);
}
/// <summary>Ouvre le menu "…" au-dessus du bouton.</summary>
+6 -2
View File
@@ -33,8 +33,12 @@
FontSize="19" FontWeight="SemiBold" TextWrapping="Wrap" />
</StackPanel>
<TextBlock x:Name="MessageText" Margin="0,18,0,0" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
<!-- Message long (détail d'une erreur…) : il défile, la fenêtre ne dépasse jamais l'écran -->
<ScrollViewer x:Name="MessageScroll" Margin="0,18,0,0" VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled" Focusable="False">
<TextBlock x:Name="MessageText" FontSize="14" LineHeight="21"
Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap" />
</ScrollViewer>
<Border x:Name="OptionPanel" Margin="0,20,0,0" Padding="16,12" CornerRadius="14"
Background="{DynamicResource InputBrush}" BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
@@ -12,6 +12,12 @@ public partial class ModernDialog : Window
private ModernDialog()
{
InitializeComponent();
// Jamais plus haute que la zone utile de l'écran de sa fenêtre parente (au-delà, le message défile).
SourceInitialized += (_, _) =>
{
const double chrome = 280; // titre, option, boutons, marges et ombre
MessageScroll.MaxHeight = Math.Max(120, Controls.WindowModeAssist.WorkArea(Owner ?? (Visual)this).Height - chrome);
};
}
public static void ShowMessage(Window? owner, string title, string message, DialogKind kind)
@@ -10,17 +10,17 @@ public partial class SettingsWindow : Window
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
Loaded += (_, _) => FitToScreen();
SourceInitialized += (_, _) => FitToScreen(); // avant le centrage sur la fenêtre parente
}
/// <summary>
/// La fenêtre ne dépasse jamais la zone utile de l'écran (mise à l'échelle 125-150 %) :
/// au-delà, les sections défilent.
/// La fenêtre ne dépasse jamais la zone utile de l'écran de sa fenêtre parente (mise à l'échelle 125-150 %, autre
/// écran) : au-delà, les sections défilent.
/// </summary>
private void FitToScreen()
{
const double chrome = 260; // en-tête, boutons, marges et ombre
SectionsScroll.MaxHeight = Math.Max(240, SystemParameters.WorkArea.Height - chrome);
SectionsScroll.MaxHeight = Math.Max(240, Controls.WindowModeAssist.WorkArea(Owner ?? (System.Windows.Media.Visual)this).Height - chrome);
}
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
@@ -187,7 +187,12 @@ public class AuditPanelTests
ActorName = "Paul,=1+1",
Details = "nom ; avec \"guillemets\"\nsur deux lignes"
};
var line = AuditTexts.ToCsv(new[] { new AuditItem(entry) }).Split(Environment.NewLine)[1];
var csv = AuditTexts.ToCsv(new[] { new AuditItem(entry), new AuditItem(entry with { KeyLabel = "PC bureau" }) }).Split(Environment.NewLine);
Assert.Equal("Date;Action;Code;Résultat;Administrateur;Appareil;IP;Clé;Cible;Détails", csv[0]);
Assert.Contains(";\"PC bureau\";\"'@SUM(A1)\";", csv[2]);
Assert.Equal("Appareil « PC bureau »", new AuditItem(entry with { KeyLabel = "PC bureau" }).DeviceText);
Assert.StartsWith("Appareil « PC bureau » · IP", new AuditItem(entry with { KeyLabel = "PC bureau" }).OriginText);
var line = csv[1];
Assert.Contains(";\"'@SUM(A1)\";", line);
Assert.Contains(";\"'=HYPERLINK(\"\"http://x\"\")\";", line);
Assert.Contains(";\"' -2+3\";", line);
@@ -52,6 +52,30 @@ public class AuditModuleTests
Assert.Contains("entrées plus anciennes", views[0].Details);
}
[Fact]
public async Task Entries_name_the_device_chosen_at_enrollment_never_the_windows_name()
{
await using var api = await TestApi.StartAsync();
using var ownerKey = api.AddOwner();
await using var owner = await api.LoginAsync(ownerKey);
var (editorKey, _) = await api.AddAdminAsync(owner, "Éditrice", AdminRoles.Editor); // appareil « poste de test »
// Launcher 2.4.1 ou plus ancien : nom Windows du PC entre parenthèses dans la description du logiciel.
await using (await api.CreateClient().LoginAsync(editorKey, "GameLauncher 2.4.1 (DESKTOP-ABC123)"))
{
}
var login = (await owner.GetAuditAsync(50)).Entries.First(e => e.Action == "auth.login" && e.KeyId == editorKey.KeyId);
Assert.Equal("poste de test", login.KeyLabel);
Assert.Equal("appareil « poste de test » · GameLauncher 2.4.1", login.Details);
Assert.DoesNotContain(Journal(api, "auth.login"), e => e.Details?.Contains("DESKTOP-ABC123") == true);
// Le nom de l'appareil est ajouté à la lecture : il n'est jamais écrit dans le fichier du journal.
Assert.All(api.Services.GetRequiredService<AuditLog>().Read(200, null).Entries, e => Assert.Null(e.KeyLabel));
// Entrée sans clé : pas d'appareil.
Assert.All((await owner.GetAuditAsync(50)).Entries.Where(e => e.KeyId is null), e => Assert.Null(e.KeyLabel));
}
[Fact]
public async Task Exports_are_recorded_and_checked()
{
+81 -45
View File
@@ -1,67 +1,103 @@
# Launcher 2.4.1
# Launcher 2.5.0
LAUNCHER - NOTES DE MISE À JOUR
Avertissement avant une publication signée - Mise à jour du 7 novembre 2026
Plein écran et nom des appareils dans l'audit - Mise à jour du 8 novembre 2026
==================================================================
Petite version du menu d'administration : avant de publier le catalogue ou une version, le launcher vérifie que la clé de votre PC est bien reconnue par les launchers des joueurs, et vous prévient sinon.
La fenêtre du launcher et le menu d'administration peuvent maintenant s'agrandir sur tout l'écran, la barre des tâches restant visible. Leur mise en page s'adapte à la place disponible, sur grand comme sur petit écran.
Rien ne change dans le fonctionnement : le serveur accepte toujours ces publications et les launchers des joueurs les traitent comme avant. Il s'agit uniquement d'un avertissement. Pas de serveur à redéployer.
Dans le menu d'administration, le journal d'audit indique désormais chaque appareil sous le nom choisi lors de l'invitation, et non plus sous le nom Windows du PC. Le serveur est à redéployer pour en profiter.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : avertissement « Clé non reconnue par les launchers des
joueurs » avant de publier le catalogue (page « Jeux »).
- NOUVEAU : même avertissement avant de publier une version et
avant « Signer la version en ligne » (page « Publier une
version »).
- AMÉLIORÉ : le message explique la conséquence pour les joueurs
et la marche à suivre.
- SERVEUR : aucun changement, rien à redéployer.
- QUALITÉ : 5 nouveaux tests automatisés (281 au total).
- NOUVEAU : plein écran pour la fenêtre du launcher et le menu
d'administration (bouton entre « Réduire » et « Fermer », ou F11).
- NOUVEAU : le mode choisi (plein écran ou fenêtré) est retenu pour
la fois suivante.
- AMÉLIORÉ : mise en page adaptée à la taille de l'écran, sans
texte ni bouton coupé.
- CORRIGÉ : le journal d'audit affichait le nom Windows du PC au
lieu du nom de l'appareil choisi à l'invitation.
- SERVEUR : à redéployer (nom des appareils dans le journal
d'audit).
- QUALITÉ : 1 nouveau test automatisé (282 au total).
------------------------------------------------------------------
AVERTISSEMENT AVANT PUBLICATION
PLEIN ÉCRAN
------------------------------------------------------------------
- Juste avant la signature, le launcher télécharge le trousseau
publié et le vérifie comme les launchers des joueurs.
- L'avertissement s'affiche si :
* la clé du PC n'est pas dans le trousseau publié ;
* la clé y est, mais sans l'autorisation voulue (catalogue ou
versions du jeu) ;
* le trousseau est refusé (autre clé racine, trop ancien,
illisible) ou injoignable ;
* aucun trousseau n'est configuré dans le launcher.
- Le message indique ce qui se passera chez les joueurs :
* vérification obligatoire : catalogue ignoré (les joueurs
gardent le précédent) ou version refusée (ni installation ni
mise à jour, le jeu déjà installé reste jouable) ;
* vérification en mode « warn » : contenu accepté, avec une
alerte dans le journal ;
* vérification désactivée : aucun avertissement.
- Deux choix : « Annuler » (rien n'est envoyé) ou « Publier quand
même » (« Signer quand même » pour une version déjà en ligne).
- Pour régler le problème : un propriétaire ajoute la clé du PC au
trousseau (page « Signature »), le fait signer avec la clé
racine puis le publie ; les joueurs acceptent alors le contenu
sans nouvelle publication.
- Concerne la fenêtre principale du launcher et le menu
d'administration ; les autres fenêtres (paramètres, notes de
version agrandies, messages) gardent leur taille.
- Bascule : bouton entre « Réduire » et « Fermer », ou touche F11.
- La fenêtre s'agrandit comme une fenêtre Windows : elle occupe tout
l'écran où elle se trouve, la barre des tâches reste visible (même
en masquage automatique).
- Le mode de chaque fenêtre est retenu pour la fois suivante.
- Les fenêtres ne se redimensionnent toujours pas par les bords ou
les coins : deux modes seulement, fenêtré ou plein écran.
- Une fenêtre réduite dans la barre des tâches ou près de l'horloge
revient dans son mode.
------------------------------------------------------------------
MISE EN PAGE ADAPTÉE À L'ÉCRAN
------------------------------------------------------------------
- Plein écran : les notes de version s'élargissent, les pages du
menu d'administration utilisent toute la largeur.
- Petit écran ou mise à l'échelle Windows 125-150 % : la fenêtre ne
dépasse plus de l'écran ; si la place manque, le contenu est
réduit d'un bloc, sans rien couper.
- Présentation du jeu : elle défile si elle ne tient pas en hauteur
(description, liens et bandeau à la fois).
- Menu d'administration :
* messages d'erreur et de résultat sur toute la largeur ;
* pages en deux colonnes (Jeux, Notes de version, Groupes) : sur
un très petit écran, la page entière défile, rien n'est coupé ;
* libellés trop longs abrégés proprement (navigation, titres).
- Paramètres, notes de version agrandies et messages : taille
limitée à l'écran de la fenêtre du launcher (utile avec plusieurs
écrans) ; un message long défile.
------------------------------------------------------------------
JOURNAL D'AUDIT : NOM DES APPAREILS
------------------------------------------------------------------
- Chaque entrée indique l'appareil sous le nom choisi lors de
l'invitation (« J'ai une invitation »), jamais le nom Windows du
PC.
- Les anciennes entrées en profitent aussi ; seules les lignes
« Connexion » déjà écrites gardent leur texte d'origine (le
journal n'est jamais réécrit).
- Export CSV : nouvelle colonne « Appareil ».
- Recherche : le nom de l'appareil est pris en compte.
- SÉCURITÉ :
* aucun changement côté serveur ni côté joueur ;
* trousseau vérifié avec la clé racine intégrée au launcher ;
* clé du PC identifiée localement, à partir de la clé qui
signe ;
* téléchargement sans cache, limité en taille et en durée ;
* une erreur imprévue du contrôle ne bloque pas la publication.
* le nom Windows du PC n'est plus envoyé au serveur ni conservé ;
le serveur retire celui qu'envoient les launchers plus anciens ;
* le nom de l'appareil est ajouté par le serveur à la lecture, il
n'est jamais écrit dans les fichiers du journal.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer : server/deploy/LISEZMOI.md, étape 8 (étapes 1 et
2). Aucun réglage nouveau.
- Sans redéploiement, la colonne « Appareil » reste vide.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Tests : nouveau fichier PlayerSigningCheckTests.cs.
- Documentation : docs/ADMIN-ARCHITECTURE.md mis à jour
(avertissement avant publication).
- Nouveaux fichiers : Controls/WindowModeAssist.cs (plein écran,
F11, mode retenu), Controls/ScaleToFitDecorator.cs (mise en page
adaptée), Platform/WindowModeStore.cs (window-modes.json).
- Une future fenêtre ou page d'administration en profite sans code
supplémentaire (règles dans docs/ADMIN-ARCHITECTURE.md, § 5.3).
- Tests : AuditModuleTests.cs et AuditPanelTests.cs complétés.
- Documentation : README.md et docs/ADMIN-ARCHITECTURE.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.