Compare commits

...
11 Commits
Author SHA1 Message Date
nicolas 14b2167218 Launcher 3.4.0 : mises à jour prioritaires
- Version 3.3.1 → 3.4.0
- Notes de version (notes-launcher.txt, RELEASE-3.4.0.md)
- README §7 : section « Version prioritaire », sécurité, publication
2026-11-10 13:35:25 +01:00
nicolas 2e4d2ee76e UiSnapshot : scène launcher-update-priority 2026-11-10 13:34:37 +01:00
nicolas 7d9032fe20 Publication : options --priority et --clear-priority
- LauncherPublisher publish : --priority (confirmation o/N), --clear-priority,
  --yes ; priorité reprise du launcher-update.json précédent s'il est signé
- Ligne « Priorité » dans publish et verify
- publish-launcher-update.bat : paramètres --priority, --clear-priority,
  --help (shift /1 pour garder %~dp0)
2026-11-10 13:34:13 +01:00
nicolas ccd6b1a430 Interface : pastille « Prioritaire » dans la fenêtre de mise à jour du launcher
Sous-titre dédié précisant que le réglage du joueur ne change pas.
2026-11-10 13:33:02 +01:00
nicolas e4a21c6f5d Launcher : version prioritaire installée même si la MAJ auto est désactivée
- Recherche au démarrage même option désactivée ; seule une version
  prioritaire est alors installée, self-update.json jamais modifié
- Erreur de téléchargement d'une version prioritaire : retentée au
  démarrage suivant
- Version prioritaire non installée : avertissement dans le bandeau et
  statut orange dans les Paramètres du launcher
2026-11-10 13:32:42 +01:00
nicolas 1a7f45c38b Tests : versions prioritaires du launcher (8 tests, 12 cas)
Signature, compatibilité sans priorité, launchers forcés ou non,
validation, reprise / retrait par l'outil de publication,
self-update.json sans champ de priorité.
2026-11-10 13:32:05 +01:00
nicolas b48d4222db Core : version prioritaire du launcher dans launcher-update.json (signée)
- LauncherRelease.PriorityVersion : dernière version prioritaire publiée,
  dans le payload signé (ignorée par les launchers < 3.4.0)
- IsPriorityRelease, IsPriorityFor(), TryGetPriorityVersion()
- ResolvePriorityVersion() : marquer, reprendre ou retirer la priorité
  d'une publication à l'autre
- Validate() refuse une version prioritaire illisible ou supérieure à la
  version publiée
- LauncherUpdateCheck.IsPriority
2026-11-10 13:31:36 +01:00
nicolas 51eecbb57a Merge pull request 'Dev/3.3.1' (#139) from dev/3.3.1 into master
Reviewed-on: #139
Reviewed-by: Nicolas Deville <nicolas.deville@lanisys.fr>
2026-11-10 12:30:21 +01:00
nicolas edd2633455 chore(release): launcher 3.3.1 2026-11-10 12:18:55 +01:00
nicolas 0e26e6eb11 docs: rafraîchissement du trousseau (3.3.1) 2026-11-10 12:18:22 +01:00
nicolas 916b53446a fix(trust): retélécharger le trousseau quand une clé connue n'est pas autorisée, âge max 30 min 2026-11-10 12:17:50 +01:00
20 changed files with 649 additions and 111 deletions

No files matched your search

+24 -2
View File
@@ -429,7 +429,7 @@ Le launcher (un seul `GameLauncher.exe`) vérifie au démarrage s'il existe une
lui-même, la télécharge, l'installe et **redémarre tout seul**. Réglable dans **Paramètres du launcher** (roue crantée) :
interrupteur « Mettre à jour le launcher automatiquement » (commun à tous les jeux) et bouton
**« Vérifier les mises à jour »** (puis « Installer la x.y.z »). Option désactivée : un bandeau signale
simplement qu'une nouvelle version existe.
simplement qu'une nouvelle version existe — sauf pour une **version prioritaire** (ci-dessous), installée quand même.
### Sécurité
@@ -441,6 +441,7 @@ simplement qu'une nouvelle version existe.
| Pas de rétrogradation | Seule une version **strictement supérieure** est installée ; le produit (`launcherId`) doit correspondre. |
| Tailles bornées | `launcher-update.json` ≤ 256 Ko ; un serveur qui envoie plus que la taille annoncée est coupé (vaut aussi pour les fichiers du jeu). |
| Retour arrière automatique | Si la nouvelle version ne démarre pas dans les 60 s (ou n'est pas la version annoncée), elle est arrêtée et l'ancienne remise en place. Cette version n'est plus proposée automatiquement (bouton manuel uniquement). |
| Version prioritaire signée | L'indication « prioritaire » fait partie du contenu signé : un serveur compromis ne peut ni imposer une mise à jour, ni retirer la priorité d'un correctif. Elle ne permet pas d'installer autre chose qu'une version signée, strictement supérieure. |
### Déroulement
@@ -463,6 +464,22 @@ La mise à jour est refusée tant qu'un jeu est lancé ou qu'un téléchargement
est dans un dossier protégé (Program Files), il ne peut pas se remplacer : le message l'explique.
Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la version publiée se remplace.
### Version prioritaire (correctif critique)
Une version publiée avec **`--priority`** est installée au démarrage **même chez les joueurs qui ont
désactivé** « Mettre à jour le launcher automatiquement ». Exemple : une faille critique est corrigée en 3.4.0.
| Point | Comportement |
|---|---|
| Réglage du joueur | **Jamais modifié** : `autoUpdate` reste à `false` dans `self-update.json`. Effet ponctuel : une fois le launcher à jour, les versions normales suivantes ne sont plus installées automatiquement chez lui. |
| Versions suivantes | La priorité est **reprise** par les publications suivantes (`priorityVersion` dans `launcher-update.json`) : si la 3.5.0 (normale) sort après le correctif 3.4.0, un launcher encore en 3.3.x passe directement en 3.5.0 ; un launcher déjà en 3.4.0 n'est pas concerné. `--clear-priority` arrête la reprise (priorité publiée par erreur). |
| Fenêtre de mise à jour | Pastille **« Prioritaire »** et texte « Mise à jour importante… votre réglage ne change pas ». « Plus tard » reste possible : nouvelle tentative au démarrage suivant, bandeau d'avertissement en attendant. |
| Erreur de téléchargement | Retentée au démarrage suivant (une version normale n'est plus retentée automatiquement). |
| La nouvelle version ne démarre pas | Retour arrière, puis plus de tentative automatique (sinon 60 s d'attente à chaque lancement) : bandeau d'avertissement et statut orange dans les Paramètres du launcher, installation par le bouton. |
| Dossier protégé (Program Files) | Pas d'installation possible sans droits administrateur : bandeau d'avertissement. |
| Option désactivée | Le launcher interroge désormais le serveur au démarrage (`launcher-update.json`, 1 à 2 Ko, 6 s au plus) pour savoir si une version prioritaire existe. |
| Launchers antérieurs à 3.4.0 | Ignorent l'indication : ils ne suivent que leur propre réglage. La priorité ne vaut que pour les joueurs déjà en 3.4.0 ou plus. |
### Mise en place (une seule fois)
1. **Clés** : double-cliquez `tools\keygen-launcher.bat`. Choisissez une phrase de passe (12 caractères
@@ -490,6 +507,9 @@ Depuis Visual Studio (F5), le bouton vérifie mais n'installe pas : seule la ver
signé par votre clé. Les launchers antérieurs à 1.1.3 reçoivent toujours le titre et la liste « à plat ».
3. Double-cliquez `tools\publish-launcher-update.bat` : compilation, signature (phrase de passe demandée),
puis envoi WinSCP — l'exe d'abord, `launcher-update.json` **en dernier**.
**Correctif critique** : depuis une invite de commandes, `tools\publish-launcher-update.bat --priority`
(confirmation demandée avant la signature). `--clear-priority` ne reprend pas la priorité déjà publiée.
Le résumé affiché indique la priorité (« normale », « PRIORITAIRE » ou « version prioritaire x.y.z reprise »).
Vérification à tout moment :
`dotnet run --project tools\LauncherPublisher -- verify --manifest https://…/launcher/launcher-update.json --public-key src\GameLauncher\Security\update-signing.pub`
@@ -640,7 +660,9 @@ bouton **Aide** des joueurs et la page **Demandes des joueurs**, puis le **mode
depuis la page **Signature** (propriétaires), qui montre aussi l'état de chaque contenu. Côté joueur,
`"keyringUrl"` et `"contentSigning"` (`"warn"` puis `"require"`) dans `launcher.json` (intégré à l'exe) ;
en « require », un manifest non signé, mal signé ou plus ancien que le dernier vu est refusé (le jeu installé
reste jouable), et un catalogue refusé est ignoré. Après le retrait d'une clé compromise, relevez
reste jouable), et un catalogue refusé est ignoré. Le trousseau est retéléchargé sans redémarrer le launcher quand
un contenu est signé par une clé absente ou non autorisée pour ce contenu (au plus une fois par minute), et au plus
toutes les 30 minutes (une clé retirée n'est plus acceptée par un launcher laissé ouvert). Après le retrait d'une clé compromise, relevez
`"keyringMinSequence"` au numéro du nouveau trousseau. Mise en place : `server/deploy/LISEZMOI.md`, étape 3 quinquies.
* **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
+9 -3
View File
@@ -911,7 +911,9 @@ plus avant la recherche des mises à jour des jeux** (pas d'attente pour `--unin
pendant une maintenance) et aussitôt après une publication depuis le menu de ce PC (`IAdminContext.NotifyMaintenanceChanged`).
8 Ko au plus, 8 s au plus ; règles contrôlées même si signé ; signature vérifiée par
`ContentTrustService.VerifyMaintenanceAsync` (autorisation `maintenance`, date de signature non antérieure) ; révision
inférieure à l'état connu refusée ; même fichier que la fois précédente : rien à revérifier. **Rien n'est gardé sur le
inférieure à l'état connu refusée ; même fichier que la fois précédente (déjà accepté) : rien à revérifier ; fichier
refusé : revérifié à chaque relecture (un trousseau re-signé avec l'autorisation `maintenance` pendant que le launcher
tourne est retéléchargé et pris en compte, 3.3.1). **Rien n'est gardé sur le
disque** : injoignable au démarrage = pas de maintenance (le jeu installé reste jouable hors ligne, un joueur sans
connexion n'est jamais bloqué) ; pendant que le launcher tourne, une coupure ou un fichier refusé garde le dernier état
vérifié, un 404 termine la maintenance.
@@ -1016,8 +1018,12 @@ Trousseau téléchargé au démarrage (en parallèle du catalogue), vérifié (s
s'il est plus ancien que le dernier vu, différent sous le même numéro, ou plus ancien que `keyringMinSequence`
(launcher.json intégré, à relever après le retrait d'une clé compromise) ; hors ligne : dernier trousseau vu
(revérifié à la lecture du cache). Aucune erreur de téléchargement (délai, réponse invalide, bogue) ne remonte à
l'appelant : le cache est utilisé. Contenu signé par une clé absente du trousseau : nouveau téléchargement, au plus
une fois toutes les 5 minutes. Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
l'appelant : le cache est utilisé. Nouveau téléchargement du trousseau (mêmes contrôles) : après un échec réseau
(1 min), quand un contenu est signé par une clé absente du trousseau **ou présente sans l'autorisation de ce contenu**
(clé ajoutée ou autorisation donnée depuis : au plus une fois par minute, `KeyChangeRetryDelay`), et quand le trousseau
en mémoire a plus de 30 minutes (`KeyringMaxAge`, à la vérification suivante : un launcher laissé ouvert cesse de faire
confiance à une clé retirée). Avant la 3.3.1, une autorisation ajoutée à une clé déjà connue n'était vue qu'au
redémarrage (mode maintenance refusé à chaque relecture après la re-signature du trousseau). Manifest (`ManifestClient`) et catalogue (`CatalogService`, avant tout contrôle des valeurs)
vérifiés : clé du trousseau autorisée, signature valide, **date de signature ≥ la dernière acceptée** pour ce
contenu (un serveur compromis ne peut pas resservir une ancienne version légitime mais vulnérable). « warn » :
journal seulement ; « require » : manifest refusé (`ContentSecurityException`, message « Mise à jour bloquée par
+8 -3
View File
@@ -286,8 +286,11 @@ Limites :
textes) ;
* un launcher accepte tout trousseau signé par la clé racine dont le numéro est au moins `keyringMinSequence`
et au moins celui du dernier trousseau qu'il a vu ;
* un appareil ajouté au trousseau pendant qu'un launcher tourne est connu de ce launcher au plus tard 5 minutes
après (nouveau téléchargement du trousseau) ou à son prochain démarrage.
* un appareil ajouté au trousseau, ou une autorisation ajoutée à un appareil, pendant qu'un launcher tourne est
connu de ce launcher dès le contenu suivant signé par cet appareil (nouveau téléchargement du trousseau, au plus une
fois par minute) ;
* un launcher laissé ouvert retélécharge le trousseau toutes les 30 minutes au plus (à la vérification suivante) : un
appareil retiré, ou une autorisation enlevée, n'y est plus accepté sans attendre son redémarrage.
La clé racine (`launcher-signing.key`) ne doit jamais aller sur le VPS ni dans Git, ni servir de clé
d'administrateur (le menu d'administration la refuse, quel que soit le nom du fichier) : c'est elle qui protège
@@ -481,7 +484,9 @@ Prérequis : étapes 3 bis (catalogue) et 3 quinquies (trousseau).
concernés y apparaît, signalée « AUTORISATIONS ÉLARGIES » par `tools\sign-keyring.bat` : c'est attendu.
5. **Activer** depuis la page « Maintenance » (message facultatif, confirmation), **désactiver** de même. Les launchers
relisent le fichier au démarrage (avant toute mise à jour automatique), puis toutes les 2 minutes (toutes les minutes
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt.
pendant une maintenance) ; celui de l'administrateur qui publie bascule aussitôt. Un launcher resté ouvert pendant
la re-signature du trousseau (point 4) prend la nouvelle autorisation en compte à la relecture suivante
(launcher 3.3.1 et suivants ; en 3.3.0, il fallait le redémarrer).
Comportement des launchers :
- **Hors ligne** ou fichier injoignable au démarrage : pas de maintenance (le jeu installé reste jouable hors ligne).
@@ -50,9 +50,58 @@ public sealed partial class LauncherRelease
/// </summary>
public List<ChangelogEntry> Changelog { get; set; } = new();
/// <summary>
/// Version PRIORITAIRE la plus récente publiée jusqu'à celle-ci incluse (ex. "3.4.0"), ou null s'il n'y en a pas.
/// Un launcher plus ancien que cette version installe la mise à jour au démarrage MÊME SI le joueur a désactivé
/// la mise à jour automatique (correctif critique). Le réglage du joueur (self-update.json) n'est jamais modifié :
/// une fois le launcher à jour, il n'est plus concerné (effet ponctuel).
/// Reprise d'une publication à l'autre par LauncherPublisher (voir <see cref="ResolvePriorityVersion"/>) : une
/// version normale publiée après un correctif critique l'impose toujours aux launchers restés trop anciens.
/// Fait partie du contenu SIGNÉ : un serveur compromis ne peut ni l'ajouter, ni la retirer, ni la modifier.
/// Ignorée par les launchers antérieurs à 3.4.0 (propriété inconnue).
/// </summary>
public string? PriorityVersion { get; set; }
[JsonIgnore]
public Version ParsedVersion => VersionUtil.Parse(Version);
/// <summary>Cette version a elle-même été publiée comme prioritaire (option --priority).</summary>
[JsonIgnore]
public bool IsPriorityRelease => TryGetPriorityVersion(out var priority) && priority == ParsedVersion;
/// <summary>
/// Vrai si un launcher en version <paramref name="currentVersion"/> doit installer cette mise à jour même avec la
/// mise à jour automatique désactivée : il est plus ancien que la dernière version prioritaire publiée.
/// </summary>
public bool IsPriorityFor(Version currentVersion) =>
TryGetPriorityVersion(out var priority) && priority > currentVersion;
/// <summary>Version prioritaire lisible (null ou illisible : faux).</summary>
public bool TryGetPriorityVersion(out Version priority)
{
priority = new Version(0, 0, 0);
return !string.IsNullOrWhiteSpace(PriorityVersion) && VersionUtil.TryParse(PriorityVersion, out priority);
}
/// <summary>
/// Version prioritaire à inscrire dans une nouvelle publication (LauncherPublisher) :
/// <list type="bullet">
/// <item><paramref name="markPriority"/> : la version publiée elle-même ;</item>
/// <item><paramref name="clearPriority"/> : aucune (priorité publiée par erreur, par exemple) ;</item>
/// <item>sinon : celle de la publication précédente (<paramref name="previous"/>, dont la signature a été
/// VÉRIFIÉE par l'appelant), si elle n'est pas supérieure à la version publiée.</item>
/// </list>
/// </summary>
public static string? ResolvePriorityVersion(Version published, bool markPriority, bool clearPriority, LauncherRelease? previous)
{
if (markPriority && clearPriority)
throw new ArgumentException("Une publication ne peut pas à la fois être prioritaire et retirer la priorité.");
if (markPriority) return VersionUtil.Display(published);
if (clearPriority || previous is null || !previous.TryGetPriorityVersion(out var carried) || carried > published)
return null;
return VersionUtil.Display(carried);
}
public void Validate()
{
if (Format != 1)
@@ -67,6 +116,12 @@ public sealed partial class LauncherRelease
throw new UpdateSecurityException("Description de mise à jour invalide : taille incorrecte.");
if (!Sha256Regex().IsMatch(Sha256 ?? ""))
throw new UpdateSecurityException("Description de mise à jour invalide : empreinte SHA-256 incorrecte.");
if (string.IsNullOrWhiteSpace(PriorityVersion)) PriorityVersion = null;
else if (!VersionUtil.TryParse(PriorityVersion, out var priority))
throw new UpdateSecurityException($"Description de mise à jour invalide : version prioritaire « {PriorityVersion} » illisible.");
else if (priority > ParsedVersion)
throw new UpdateSecurityException(
$"Description de mise à jour invalide : version prioritaire {PriorityVersion} supérieure à la version publiée ({Version}).");
Notes ??= new List<string>();
// Textes affichés tels quels dans l'interface : mêmes bornes et nettoyage que les notes du jeu.
Changelog = ChangelogLimits.Normalize(Changelog);
@@ -10,6 +10,12 @@ public sealed record LauncherUpdateCheck(Version CurrentVersion, LauncherRelease
public bool IsAvailable => Release is not null && DownloadUri is not null;
public string CurrentDisplay => VersionUtil.Display(CurrentVersion);
public string AvailableDisplay => Release is null ? "" : VersionUtil.Display(Release.ParsedVersion);
/// <summary>
/// Mise à jour PRIORITAIRE pour ce launcher (plus ancien que la dernière version prioritaire publiée) :
/// installée au démarrage même si la mise à jour automatique est désactivée. Information signée.
/// </summary>
public bool IsPriority => IsAvailable && Release!.IsPriorityFor(CurrentVersion);
}
/// <summary>
@@ -41,8 +41,23 @@ public sealed class ContentSecurityException : Exception
public sealed class ContentTrustService
{
private static readonly TimeSpan FetchTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
private static readonly TimeSpan UnknownKeyRetryDelay = TimeSpan.FromMinutes(5);
/// <summary>Nouvel essai après un échec réseau (trousseau jamais obtenu pendant ce démarrage).</summary>
public static readonly TimeSpan RetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Nouveau téléchargement, au plus à ce rythme, quand un contenu est signé par une clé absente du trousseau ou non
/// autorisée pour ce contenu : clé ajoutée ou autorisation donnée (ex. « maintenance ») pendant que le launcher tourne.
/// Plus court que la relecture du mode maintenance, pour qu'une activation juste après la republication du trousseau
/// soit vue à la relecture suivante.
/// </summary>
public static readonly TimeSpan KeyChangeRetryDelay = TimeSpan.FromMinutes(1);
/// <summary>
/// Âge maximal du trousseau en mémoire : au-delà, il est retéléchargé à la vérification suivante (une clé retirée ou
/// une autorisation enlevée est prise en compte sans redémarrer le launcher).
/// </summary>
public static readonly TimeSpan KeyringMaxAge = TimeSpan.FromMinutes(30);
private static readonly TimeSpan MaxFutureSignedAt = TimeSpan.FromDays(1);
private readonly Func<Uri, long, CancellationToken, Task<byte[]>> _download;
@@ -82,12 +97,15 @@ public sealed class ContentTrustService
/// <summary>
/// Trousseau à utiliser : celui du serveur s'il est valide et pas plus ancien que le dernier vu, sinon le dernier
/// vu (cache), sinon null. Téléchargé une fois par démarrage (nouvel essai après un échec réseau, ou après quelques
/// minutes quand un contenu est signé par une clé encore inconnue : clé ajoutée pendant que le launcher tourne).
/// vu (cache), sinon null. Téléchargé au démarrage, puis de nouveau :
/// • après un échec réseau (<see cref="RetryDelay"/>) ;
/// • quand un contenu est signé par une clé absente ou non autorisée pour ce contenu (<see cref="KeyChangeRetryDelay"/>) ;
/// • quand il a plus de <see cref="KeyringMaxAge"/> (launcher ouvert longtemps).
/// Chaque nouveau trousseau passe par les mêmes contrôles (signature racine, numéro jamais en baisse).
/// </summary>
public Task<KeyringDocument?> GetKeyringAsync(CancellationToken ct = default) => GetKeyringAsync(false, ct);
private async Task<KeyringDocument?> GetKeyringAsync(bool unknownKey, CancellationToken ct)
private async Task<KeyringDocument?> GetKeyringAsync(bool keyChanged, CancellationToken ct)
{
Task<KeyringDocument?> task;
lock (_lock)
@@ -95,7 +113,8 @@ public sealed class ContentTrustService
var elapsed = _time.GetUtcNow() - _lastAttempt;
if (_keyringTask is null
|| (_keyringTask.IsCompleted && !_keyringFetched && elapsed > RetryDelay)
|| (_keyringTask.IsCompleted && unknownKey && elapsed > UnknownKeyRetryDelay))
|| (_keyringTask.IsCompleted && keyChanged && elapsed > KeyChangeRetryDelay)
|| (_keyringTask.IsCompleted && elapsed > KeyringMaxAge))
{
_lastAttempt = _time.GetUtcNow();
_keyringTask = FetchKeyringAsync();
@@ -151,7 +170,7 @@ public sealed class ContentTrustService
}
catch (Exception ex) when (ex is InvalidDataException or JsonException or NotSupportedException or FormatException)
{
lock (_lock) _keyringFetched = true; // contenu refusé : pas de nouvel essai pendant ce démarrage
lock (_lock) _keyringFetched = true; // contenu refusé : nouvel essai seulement aux conditions de GetKeyringAsync
Log.Error($"Trousseau du serveur refusé : {ex.Message}" + (cached is null ? "" : $" (le n° {cached.Sequence} déjà vu reste utilisé)."));
return cached;
}
@@ -274,8 +293,9 @@ public sealed class ContentTrustService
private async Task VerifyAsync(string kind, string id, string digest, ContentSignature? signature, string what, CancellationToken ct)
{
var keyring = await GetKeyringAsync(false, ct).ConfigureAwait(false);
// Clé inconnue (ajoutée depuis le démarrage) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && signature?.KeyId is { } keyId && !keyring.Keys.Any(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal)))
// Clé absente du trousseau, ou présente sans l'autorisation de ce contenu (clé ajoutée, ou autorisation donnée,
// depuis le dernier téléchargement) : nouveau téléchargement du trousseau, limité dans le temps.
if (keyring is not null && KeyMayBeNewer(keyring, kind, signature))
keyring = await GetKeyringAsync(true, ct).ConfigureAwait(false);
string problem;
@@ -322,6 +342,14 @@ public sealed class ContentTrustService
if (!Report(what, problem)) throw new ContentSecurityException($"{what} : {problem}.");
}
/// <summary>La signature vient d'une clé que ce trousseau ne connaît pas, ou n'autorise pas pour ce contenu.</summary>
private static bool KeyMayBeNewer(KeyringDocument keyring, string kind, ContentSignature? signature)
{
if (signature?.KeyId is not { } keyId) return false;
var key = keyring.Keys.FirstOrDefault(k => string.Equals(k.KeyId, keyId, StringComparison.Ordinal));
return key?.Scopes is not { } scopes || !scopes.Contains(ContentKinds.ScopeOf(kind), StringComparer.Ordinal);
}
// ------------------------------------------------------------------ État
private sealed class TrustState
+9 -2
View File
@@ -899,7 +899,8 @@ public partial class App : Application, ILauncherRestartHost
/// <summary>
/// Nouvelle version non installée automatiquement (option désactivée, échec précédent,
/// dossier protégé) : simple information dans le bandeau.
/// dossier protégé, « Plus tard ») : simple information dans le bandeau, en avertissement pour une
/// version prioritaire.
/// </summary>
private async Task NotifyLauncherUpdateAsync()
{
@@ -907,7 +908,13 @@ public partial class App : Application, ILauncherRestartHost
{
await Task.Delay(TimeSpan.FromSeconds(4));
var check = _updates!.SkippedUpdate ?? await _updates.CheckAsync();
if (check.IsAvailable)
if (check.IsPriority)
// Correctif critique non installé au démarrage (« Plus tard », échec précédent, dossier protégé…)
_launcher?.SelectedGame?.ShowNotice("Mise à jour importante du launcher",
$"La version {check.AvailableDisplay} corrige un problème important et n'est pas encore installée : " +
"Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Warning);
else if (check.IsAvailable)
_launcher?.SelectedGame?.ShowNotice("Nouvelle version du launcher",
$"La version {check.AvailableDisplay} est disponible : Paramètres du launcher (roue crantée) › Vérifier les mises à jour.",
DialogKind.Info);
+1 -1
View File
@@ -17,7 +17,7 @@
(1.0.0 → 1.0.1 → 1.1.0…). Les launchers des joueurs ne se mettent à jour que vers
une version STRICTEMENT supérieure à la leur.
-->
<Version>3.3.0</Version>
<Version>3.4.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -128,12 +128,15 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
/// <summary>
/// Au démarrage, avant l'affichage de la fenêtre principale : recherche rapide et, si une
/// nouvelle version existe et que la mise à jour automatique est activée, installation.
/// Mise à jour automatique désactivée : la recherche a lieu quand même, et seule une version
/// PRIORITAIRE (correctif critique, information signée) est installée. Le réglage du joueur
/// (self-update.json) n'est jamais modifié : il reste désactivé pour les versions suivantes.
/// Renvoie vrai si le launcher redémarre (l'appelant doit alors s'arrêter là).
/// </summary>
public async Task<bool> RunStartupCheckAsync()
{
ExecutableSwapper.CleanupLeftovers(TargetPath, CurrentVersionValue);
if (_client is null || !AutoUpdate || !IsSingleFileBuild) return false;
if (_client is null || !IsSingleFileBuild) return false;
LauncherUpdateCheck check;
try
@@ -148,6 +151,18 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
}
if (!check.IsAvailable) return false;
if (!AutoUpdate && !check.IsPriority)
{
// Option désactivée, version normale : simple information dans le bandeau (sans seconde recherche).
SkippedUpdate = check;
return false;
}
if (check.IsPriority)
Log.Warn($"Launcher {check.AvailableDisplay} : mise à jour PRIORITAIRE (correctif publié en {check.Release!.PriorityVersion})" +
(AutoUpdate ? "." : ", installée bien que la mise à jour automatique soit désactivée (réglage conservé)."));
// Échec précédent : même prioritaire, pas de nouvelle tentative automatique (une version qui ne démarre pas
// sur ce PC imposerait 60 s d'attente et un retour arrière à chaque lancement). Bandeau + bouton manuel.
if (string.Equals(_settings.FailedVersion, check.AvailableDisplay, StringComparison.OrdinalIgnoreCase))
{
Log.Warn($"Launcher {check.AvailableDisplay} : installation automatique ignorée (échec précédent). " +
@@ -167,7 +182,10 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
SkippedUpdate = check;
return false;
}
return await InstallAsync(check, automatic: true);
if (await InstallAsync(check, automatic: true)) return true;
// Version prioritaire non installée (« Plus tard », erreur) : rappel dans le bandeau une fois la fenêtre affichée.
if (check.IsPriority) SkippedUpdate = check;
return false;
}
// ------------------------------------------------------------------ Installation
@@ -265,10 +283,17 @@ public sealed class LauncherUpdateService : ILauncherUpdateService
Log.Error("Mise à jour du launcher impossible", ex);
// Au démarrage, une erreur persistante (fichier absent du serveur, antivirus…) ne doit
// pas réapparaître à chaque lancement : cette version ne sera plus installée automatiquement.
if (automatic) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
// Exception : une version PRIORITAIRE (correctif critique) est retentée au prochain démarrage
// (erreur de téléchargement le plus souvent passagère). Seul un échec de démarrage de la nouvelle
// version (plus haut, après retour arrière) l'écarte des installations automatiques.
var retryAtNextStart = automatic && update.IsPriority;
if (automatic && !retryAtNextStart) RememberFailure(update);
vm.SetError(FriendlyMessage(ex) + (retryAtNextStart
? "\n\nCette mise à jour est importante : elle sera retentée au prochain démarrage du launcher " +
"(ou dès maintenant depuis Paramètres du launcher › Vérifier les mises à jour)."
: automatic
? "\n\nVous pourrez réessayer depuis Paramètres du launcher › Vérifier les mises à jour."
: ""));
}
// Erreur : la fenêtre reste ouverte jusqu'à ce que l'utilisateur la ferme.
@@ -116,9 +116,19 @@ public sealed class LauncherSettingsViewModel : SettingsViewModelBase
if (check.IsAvailable)
{
SetAvailable(check);
LauncherStatusKind = DialogKind.Info;
var title = string.IsNullOrWhiteSpace(check.Release?.Title) ? "" : $" — {check.Release!.Title}";
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
if (check.IsPriority)
{
// Correctif critique non installé au démarrage (annulé, échec, dossier protégé…)
LauncherStatusKind = DialogKind.Warning;
LauncherStatus = $"Mise à jour importante {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}). " +
"Installez-la dès que possible.";
}
else
{
LauncherStatusKind = DialogKind.Info;
LauncherStatus = $"Nouvelle version {check.AvailableDisplay} disponible{title} (actuelle : {check.CurrentDisplay}).";
}
}
else
{
@@ -21,13 +21,19 @@ public sealed class LauncherUpdateViewModel : ObservableObject
public LauncherUpdateViewModel(LauncherUpdateCheck update, bool automatic)
{
Automatic = automatic;
IsPriority = update.IsPriority;
FromVersion = update.CurrentDisplay;
ToVersion = update.AvailableDisplay;
// Notes de toutes les versions publiées depuis la version installée (format enrichi)
Changelog = update.Release?.GetChangelogSince(update.CurrentVersion) ?? Array.Empty<ChangelogEntry>();
Subtitle = automatic
? "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul."
: "Installation de la nouvelle version. Le launcher redémarrera tout seul.";
Subtitle = (automatic, IsPriority) switch
{
(true, true) => "Mise à jour importante : elle s'installe automatiquement, même si la mise à jour automatique " +
"du launcher est désactivée (votre réglage ne change pas). Le launcher redémarrera tout seul.",
(true, false) => "Une nouvelle version est disponible. Elle s'installe automatiquement, le launcher redémarrera tout seul.",
(false, true) => "Installation d'une mise à jour importante. Le launcher redémarrera tout seul.",
_ => "Installation de la nouvelle version. Le launcher redémarrera tout seul."
};
Progress = new Progress<OperationProgress>(p =>
{
@@ -44,6 +50,9 @@ public sealed class LauncherUpdateViewModel : ObservableObject
/// <summary>Recherche au démarrage (vrai) ou demandée dans les Paramètres du launcher (faux).</summary>
public bool Automatic { get; }
/// <summary>Mise à jour prioritaire (correctif critique, information signée) : pastille « Prioritaire ».</summary>
public bool IsPriority { get; }
public string FromVersion { get; }
public string ToVersion { get; }
public string Subtitle { get; }
@@ -76,6 +76,16 @@
<TextBlock Text="{Binding ToVersion}" FontSize="13" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- Mise à jour prioritaire (correctif critique) : installée même si l'option automatique est désactivée -->
<Border Style="{StaticResource VersionChipStyle}" Margin="10,0,0,0" BorderBrush="{DynamicResource WarningBrush}"
ToolTip="Correctif important : installé même si la mise à jour automatique du launcher est désactivée. Votre réglage ne change pas."
Visibility="{Binding IsPriority, Converter={StaticResource BoolToVisibility}}">
<StackPanel Orientation="Horizontal">
<Path Data="{StaticResource Icon.Alert}" Fill="{DynamicResource WarningBrush}"
Width="14" Height="14" Stretch="Uniform" VerticalAlignment="Center" Margin="0,0,7,0" />
<TextBlock Text="Prioritaire" FontSize="12.5" FontWeight="SemiBold" VerticalAlignment="Center" />
</StackPanel>
</Border>
</StackPanel>
<!-- Nouveautés : même présentation que les notes du jeu (Themes/Changelog.xaml) -->
@@ -271,6 +271,74 @@ public class ContentTrustTests
Assert.Equal(2, server.Requests);
}
[Fact]
public async Task A_scope_granted_while_the_launcher_runs_is_found_by_downloading_the_keyring_again()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases)));
var clock = new ManualClock();
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
var catalog = new CatalogDocument { Revision = 1, Games = new Dictionary<string, CatalogGame> { ["EZR"] = new() { Tagline = "Texte" } } };
catalog = catalog with
{
Signature = SigningKit.Sign(nicolas, ContentKinds.Catalog, ContentKinds.CatalogId, ContentSigning.CatalogDigest(catalog))
};
Assert.False(await trust.VerifyCatalogAsync(catalog)); // clé connue, sans l'autorisation « catalog »
Assert.Equal(1, server.Requests);
// Trousseau republié avec l'autorisation (ex. « maintenance » après la mise à jour 3.3) : vu sans redémarrer,
// au plus une fois par KeyChangeRetryDelay.
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog)));
Assert.False(await trust.VerifyCatalogAsync(catalog));
Assert.Equal(1, server.Requests);
clock.Now += ContentTrustService.KeyChangeRetryDelay + TimeSpan.FromSeconds(1);
Assert.True(await trust.VerifyCatalogAsync(catalog));
Assert.Equal(2, server.Requests);
Assert.Equal(2, trust.CachedKeyring!.Sequence);
}
[Fact]
public async Task A_launcher_left_open_downloads_the_keyring_again_and_stops_trusting_a_removed_key()
{
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var nicolas = SigningKit.NewKey();
using var compromised = SigningKit.NewKey();
var server = new FakeServer();
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
var clock = new ManualClock();
var trust = Trust(server, folder.Path, root, ContentSigningMode.Require, time: clock);
await trust.VerifyManifestAsync(Manifest("1.2", compromised), "EZR");
// Clé retirée du trousseau (n° 2) : encore acceptée tant que le trousseau en mémoire est récent…
server.Serve(SigningKit.Keyring(root, 2, SigningKit.Entry(nicolas, "Nicolas")));
clock.Now += TimeSpan.FromMinutes(10);
await trust.VerifyManifestAsync(Manifest("1.3", compromised), "EZR");
Assert.Equal(1, server.Requests);
// … puis refusée dès qu'il a dépassé son âge maximal (sans redémarrer le launcher).
clock.Now += ContentTrustService.KeyringMaxAge;
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.4", compromised), "EZR"));
Assert.Equal(2, server.Requests);
await trust.VerifyManifestAsync(Manifest("1.4", nicolas), "EZR");
// Serveur injoignable au téléchargement suivant : dernier trousseau vu (n° 2) conservé, pas d'erreur.
server.Keyring = null;
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
await trust.VerifyManifestAsync(Manifest("1.5", nicolas), "EZR");
Assert.Equal(3, server.Requests);
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.6", compromised), "EZR"));
// Serveur compromis qui ressert l'ancien trousseau n° 1 : refusé (retour en arrière), la clé reste exclue.
server.Serve(SigningKit.Keyring(root, 1, SigningKit.Entry(nicolas, "Nicolas"), SigningKit.Entry(compromised, "PC volé")));
clock.Now += ContentTrustService.KeyringMaxAge + TimeSpan.FromMinutes(1);
await Assert.ThrowsAsync<ContentSecurityException>(() => trust.VerifyManifestAsync(Manifest("1.7", compromised), "EZR"));
Assert.Equal(2, trust.CachedKeyring!.Sequence);
}
[Fact]
public async Task A_signature_dated_far_in_the_future_does_not_block_later_releases()
{
@@ -0,0 +1,139 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using GameLauncher.Core.SelfUpdate;
using GameLauncher.Core.Utils;
namespace GameLauncher.Tests.Signing;
/// <summary>
/// Version PRIORITAIRE du launcher (correctif critique) : installée au démarrage même si le joueur a désactivé la mise
/// à jour automatique. Information signée, reprise d'une publication à l'autre, sans effet sur les launchers à jour.
/// </summary>
public class LauncherPriorityTests
{
private static LauncherRelease Release(string version, string? priority = null) => new()
{
Product = "GameLauncher",
Version = version,
File = $"GameLauncher-{version}.exe",
Size = 1234,
Sha256 = new string('a', 64),
PublishedAt = DateTimeOffset.UnixEpoch,
PriorityVersion = priority
};
private static (string Envelope, UpdateSignature.TrustedKey Key) Sign(LauncherRelease release, ECDsa key)
{
var payload = JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options);
var envelope = UpdateSignature.CreateEnvelope(payload, key);
var spki = key.ExportSubjectPublicKeyInfo();
return (envelope, new UpdateSignature.TrustedKey(UpdateSignature.ComputeKeyId(spki), spki));
}
private static LauncherUpdateCheck Check(string current, LauncherRelease? release) =>
new(VersionUtil.Parse(current), release, release is null ? null : new Uri("https://jeux.exemple.test/launcher/" + release.File));
[Fact]
public void The_priority_version_is_part_of_the_signed_content()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var (envelope, trusted) = Sign(Release("3.4.0", priority: "3.4.0"), key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Equal("3.4.0", read.PriorityVersion);
Assert.True(read.IsPriorityRelease);
// Serveur compromis : retirer (ou ajouter) la priorité dans le payload casse la signature.
var json = JsonSerializer.Deserialize<SignedEnvelope>(envelope, JsonDefaults.Options)!;
var tampered = Encoding.UTF8.GetString(Convert.FromBase64String(json.Payload)).Replace("\"3.4.0\"", "\"3.3.9\"");
json.Payload = Convert.ToBase64String(Encoding.UTF8.GetBytes(tampered));
var forged = JsonSerializer.Serialize(json, JsonDefaults.Options);
Assert.Throws<UpdateSecurityException>(() => UpdateSignature.ReadRelease(forged, new[] { trusted }));
}
[Fact]
public void A_release_without_priority_stays_compatible_with_older_launchers()
{
using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
var release = Release("3.4.0");
var payload = Encoding.UTF8.GetString(JsonSerializer.SerializeToUtf8Bytes(release, JsonDefaults.Options));
Assert.DoesNotContain("priorityVersion", payload, StringComparison.OrdinalIgnoreCase); // champ absent : rien ne change
var (envelope, trusted) = Sign(release, key);
var read = UpdateSignature.ReadRelease(envelope, new[] { trusted });
Assert.Null(read.PriorityVersion);
Assert.False(read.IsPriorityRelease);
Assert.False(Check("3.3.1", read).IsPriority);
Assert.True(Check("3.3.1", read).IsAvailable);
}
[Theory]
[InlineData("3.3.1", true)] // plus ancien que le correctif : mise à jour imposée
[InlineData("3.3.9", true)]
[InlineData("3.4.0", false)] // correctif déjà installé : réglage du joueur respecté pour 3.5.0
[InlineData("3.4.2", false)]
public void Only_launchers_older_than_the_priority_version_are_forced(string current, bool forced)
{
var release = Release("3.5.0", priority: "3.4.0"); // 3.4.0 prioritaire, puis 3.5.0 normale
Assert.Equal(forced, release.IsPriorityFor(VersionUtil.Parse(current)));
Assert.Equal(forced, Check(current, release).IsPriority);
Assert.False(release.IsPriorityRelease);
}
[Fact]
public void No_update_available_means_no_priority()
{
Assert.False(Check("3.4.0", null).IsPriority);
Assert.False(new LauncherUpdateCheck(VersionUtil.Parse("3.3.0"), Release("3.4.0", "3.4.0"), null).IsPriority);
}
[Theory]
[InlineData("3.6.0")] // supérieure à la version publiée
[InlineData("pas-une-version")]
public void An_inconsistent_priority_version_is_refused(string priority)
{
var release = Release("3.5.0", priority);
Assert.Throws<UpdateSecurityException>(release.Validate);
}
[Fact]
public void A_blank_priority_version_means_none()
{
var release = Release("3.5.0", " ");
release.Validate();
Assert.Null(release.PriorityVersion);
}
[Fact]
public void The_publisher_marks_carries_over_or_clears_the_priority()
{
var v350 = VersionUtil.Parse("3.5.0");
var previousPriority = Release("3.4.0", priority: "3.4.0");
var previousNormal = Release("3.4.0");
// --priority : la version publiée elle-même
Assert.Equal("3.5.0", LauncherRelease.ResolvePriorityVersion(v350, markPriority: true, clearPriority: false, previousNormal));
// version normale après un correctif critique : priorité reprise (les launchers < 3.4.0 restent forcés)
Assert.Equal("3.4.0", LauncherRelease.ResolvePriorityVersion(v350, false, false, previousPriority));
// --clear-priority : plus aucune version imposée
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, true, previousPriority));
// rien à reprendre
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, previousNormal));
Assert.Null(LauncherRelease.ResolvePriorityVersion(v350, false, false, null));
// priorité précédente supérieure à la version publiée (--force vers une version inférieure) : non reprise
Assert.Null(LauncherRelease.ResolvePriorityVersion(VersionUtil.Parse("3.3.5"), false, false, previousPriority));
// options contradictoires
Assert.Throws<ArgumentException>(() => LauncherRelease.ResolvePriorityVersion(v350, true, true, null));
}
[Fact]
public void The_player_setting_file_has_no_priority_field()
{
// Effet ponctuel : la priorité vient UNIQUEMENT de la description signée, jamais de self-update.json.
var settings = new SelfUpdateSettings { AutoUpdate = false };
var json = JsonSerializer.Serialize(settings, JsonDefaults.Options);
Assert.DoesNotContain("priority", json, StringComparison.OrdinalIgnoreCase);
Assert.False(JsonSerializer.Deserialize<SelfUpdateSettings>(json, JsonDefaults.Options)!.AutoUpdate);
}
}
@@ -166,6 +166,50 @@ public class MaintenanceServiceTests
Assert.Empty(changes);
}
/// <summary>Horloge avancée à la main (trousseau).</summary>
private sealed class ManualClock : TimeProvider
{
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
public override DateTimeOffset GetUtcNow() => Now;
}
[Fact]
public async Task A_maintenance_published_after_the_keyring_gained_the_scope_is_applied_without_restarting()
{
// Cas signalé en 3.3.0 : launcher ouvert, trousseau republié avec l'autorisation « maintenance » (étape 3 decies),
// puis maintenance activée. Le launcher gardait l'ancien trousseau (clé connue, sans l'autorisation) jusqu'au
// redémarrage : chaque relecture refusait la maintenance.
using var folder = new TempFolder();
using var root = SigningKit.NewKey();
using var admin = SigningKit.NewKey();
var server = new FakeServer
{
Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 1,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog))))
};
var clock = new ManualClock();
var trust = new ContentTrustService(server.Download, folder.Path, KeyringUrl, new[] { SigningKit.Root(root) },
ContentSigningMode.Require, time: clock);
var service = new MaintenanceService(server.Download, MaintenanceUrl, trust, clock);
var changes = new List<MaintenanceDocument?>();
service.Changed += changes.Add;
// Démarrage : rien de publié, trousseau n° 1 en mémoire.
Assert.NotNull(await trust.GetKeyringAsync());
Assert.False(await service.RefreshAsync());
// Trousseau n° 2 (autorisation « maintenance » ajoutée), puis maintenance activée quelques minutes plus tard.
server.Keyring = Encoding.UTF8.GetBytes(Keyring.Serialize(SigningKit.Keyring(root, 2,
SigningKit.Entry(admin, "Nicolas", SigningScopes.Releases, SigningScopes.Catalog, SigningScopes.Maintenance))));
clock.Now += TimeSpan.FromMinutes(3);
server.Serve(Signed(admin, 1, true, "Retour vers 18 h."));
// Relecture périodique suivante : trousseau retéléchargé, maintenance appliquée.
Assert.True(await service.RefreshAsync());
Assert.True(service.IsActive);
Assert.Equal("Retour vers 18 h.", Assert.Single(changes)!.Message);
}
[Fact]
public async Task Offline_at_startup_or_without_address_there_is_no_maintenance_and_warn_mode_applies_it()
{
+68 -11
View File
@@ -20,6 +20,7 @@ namespace LauncherPublisher;
/// --config src\GameLauncher\launcher.json --output "D:\Publish\launcher"
/// [--version 1.2.0] [--notes notes-launcher.txt]
/// [--public-key src\GameLauncher\Security\update-signing.pub]
/// [--priority | --clear-priority] [--yes]
/// LauncherPublisher verify --manifest "D:\Publish\launcher\launcher-update.json"
/// --public-key src\GameLauncher\Security\update-signing.pub [--exe fichier.exe]
///
@@ -49,6 +50,11 @@ public static class Program
default: return Usage();
}
}
catch (OperationCanceledException ex)
{
Warn(ex.Message);
return 1;
}
catch (Exception ex)
{
Error($"Erreur : {ex.Message}");
@@ -136,6 +142,22 @@ public static class Program
@"Augmentez <Version> dans src\GameLauncher\GameLauncher.csproj, recompilez le launcher, puis relancez. (--force pour passer outre)");
}
// Version PRIORITAIRE (correctif critique) : installée au démarrage même chez les joueurs qui ont désactivé la
// mise à jour automatique (leur réglage n'est pas modifié). Confirmation demandée : rien n'est encore écrit.
var markPriority = o.Has("--priority");
var clearPriority = o.Has("--clear-priority");
if (markPriority && clearPriority)
throw new ArgumentException("--priority et --clear-priority sont incompatibles.");
if (markPriority && !o.Has("--yes"))
{
Warn($"Version PRIORITAIRE : le launcher {display} sera installé au démarrage MÊME chez les joueurs qui ont désactivé " +
"la mise à jour automatique (leur réglage n'est pas modifié). À réserver aux correctifs critiques (faille de sécurité…).");
Console.Write("Publier cette version comme prioritaire ? (o/N) ");
var answer = Console.ReadLine()?.Trim().ToLowerInvariant();
if (answer is not ("o" or "oui"))
throw new OperationCanceledException("Abandon : rien n'a été publié.");
}
using var key = LoadPrivateKey(keyPath);
var keyId = UpdateSignature.ComputeKeyId(key.ExportSubjectPublicKeyInfo());
@@ -172,10 +194,20 @@ public static class Program
ChangelogParser.Parse(await File.ReadAllLinesAsync(notesFile), entry);
}
// Historique : notes des versions déjà publiées (launcher-update.json précédent, signé par cette clé)
var history = ReadHistory(previousJson, key, keyId, display);
// launcher-update.json précédent, signé par cette clé : historique des notes et version prioritaire repris
var previousRelease = ReadPreviousRelease(previousJson, key, keyId);
var history = ReadHistory(previousRelease, display);
var changelog = ChangelogLimits.Normalize(new[] { entry }.Concat(history));
// Version prioritaire : celle-ci (--priority), aucune (--clear-priority), sinon celle déjà publiée
var priorityVersion = LauncherRelease.ResolvePriorityVersion(version, markPriority, clearPriority, previousRelease);
if (previousRelease is null && previousJson is not null && !markPriority && !clearPriority &&
TryReadUnverified(previousJson)?.PriorityVersion is { Length: > 0 } unverifiedPriority)
Warn($"Version prioritaire {unverifiedPriority} du launcher-update.json précédent NON reprise (signature non vérifiée) : " +
"relancez avec --priority si cette version doit rester imposée.");
if (clearPriority && previousRelease?.PriorityVersion is { Length: > 0 } removedPriority)
Warn($"Version prioritaire {removedPriority} retirée : seuls les joueurs ayant activé la mise à jour automatique recevront cette version.");
var release = new LauncherRelease
{
Product = product,
@@ -187,7 +219,8 @@ public static class Program
// Format historique, lu par les launchers antérieurs à 1.1.3
Title = entry.Title,
Notes = entry.Changes.ToList(),
Changelog = changelog
Changelog = changelog,
PriorityVersion = priorityVersion
};
release.Validate();
@@ -219,6 +252,7 @@ public static class Program
? " Notes : aucune (--notes non fourni ou fichier vide)"
: $" Notes : « {current.Title ?? "sans titre"} », {current.Sections.Count} section(s), " +
$"{current.Sections.Sum(x => x.Items.Count)} élément(s) ; historique : {release.Changelog.Count - 1} version(s)");
Console.WriteLine($" Priorité : {DescribePriority(release)}");
Console.WriteLine($" Produit : {product}");
Console.WriteLine($" Clé : {keyId}");
Console.WriteLine($" SHA-256 : {sha256}");
@@ -245,6 +279,7 @@ public static class Program
Console.WriteLine($" Version : {release.Version} ({release.PublishedAt:yyyy-MM-dd HH:mm})");
Console.WriteLine($" Fichier : {release.File} ({Format.Bytes(release.Size)})");
Console.WriteLine($" SHA-256 : {release.Sha256}");
Console.WriteLine($" Priorité: {DescribePriority(release)}");
var exe = o.Get("--exe");
if (exe is null) return true;
@@ -295,25 +330,30 @@ public static class Program
}
/// <summary>
/// Notes des versions déjà publiées, reprises du launcher-update.json précédent — uniquement
/// si sa signature est valide pour la clé utilisée (un fichier modifié à la main est ignoré).
/// launcher-update.json précédent — uniquement si sa signature est valide pour la clé utilisée (un fichier
/// modifié à la main est ignoré) : son historique des notes et sa version prioritaire sont repris.
/// </summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(string? previousJson, ECDsa key, string keyId, string version)
private static LauncherRelease? ReadPreviousRelease(string? previousJson, ECDsa key, string keyId)
{
if (previousJson is null) return Array.Empty<ChangelogEntry>();
LauncherRelease previous;
if (previousJson is null) return null;
try
{
previous = UpdateSignature.ReadRelease(previousJson, new[]
return UpdateSignature.ReadRelease(previousJson, new[]
{
new UpdateSignature.TrustedKey(keyId, key.ExportSubjectPublicKeyInfo())
});
}
catch (UpdateSecurityException ex)
{
Warn($"Historique des notes ignoré ({ex.Message}).");
return Array.Empty<ChangelogEntry>();
Warn($"launcher-update.json précédent ignoré ({ex.Message}) : historique des notes et version prioritaire non repris.");
return null;
}
}
/// <summary>Notes des versions déjà publiées (publication précédente vérifiée), sans la version en cours.</summary>
private static IReadOnlyList<ChangelogEntry> ReadHistory(LauncherRelease? previous, string version)
{
if (previous is null) return Array.Empty<ChangelogEntry>();
// Version publiée avant le format enrichi : son titre et sa liste deviennent une entrée
var entries = previous.Changelog.Count > 0
@@ -322,6 +362,16 @@ public static class Program
return entries.Where(e => !string.Equals(e.Version, version, StringComparison.OrdinalIgnoreCase)).ToList();
}
/// <summary>Ligne « Priorité » affichée après publish et verify.</summary>
private static string DescribePriority(LauncherRelease release)
{
if (!release.TryGetPriorityVersion(out var priority)) return "normale (installée selon le réglage de chaque joueur)";
var display = VersionUtil.Display(priority);
return release.IsPriorityRelease
? "PRIORITAIRE — installée au démarrage même si la mise à jour automatique est désactivée (réglage du joueur conservé)"
: $"normale, version prioritaire {display} reprise — les launchers antérieurs à {display} se mettent à jour quel que soit leur réglage";
}
private static LauncherRelease? TryReadUnverified(string envelopeJson)
{
try
@@ -509,7 +559,14 @@ public static class Program
(--config <launcher.json> | --product <launcherId>)
[--version x.y.z] [--notes <notes.txt>] [--title <texte>]
[--public-key <update-signing.pub>] [--force]
[--priority | --clear-priority] [--yes]
Signe la nouvelle version et prépare le dossier à envoyer sur le serveur.
--priority : version PRIORITAIRE (correctif critique), installée au démarrage même
chez les joueurs qui ont désactivé la mise à jour automatique (leur
réglage n'est pas modifié). Confirmation demandée, sauf --yes.
Reprise par les publications suivantes : un launcher plus ancien que
cette version se met à jour quoi qu'il arrive.
--clear-priority : ne reprend pas la version prioritaire déjà publiée.
verify --manifest <launcher-update.json | https://…> --public-key <update-signing.pub> [--exe <fichier>]
Vérifie la signature (et l'exécutable).
+2 -1
View File
@@ -36,7 +36,8 @@ Les positions du rapport sont en unités WPF (multiplier par `--scale` pour les
`main-maintenance-plain` sans précision, jeu non installé) ; `main-multi` : plusieurs jeux (exemple
`launcher.multi-jeux.example.json`).
* **Fenêtres** : paramètres du jeu et du launcher, notes de version agrandies, choix du dossier
d'installation, mise à jour du launcher (téléchargement, version vérifiée, erreur), boîtes de dialogue
d'installation, mise à jour du launcher (téléchargement, version vérifiée, version prioritaire
`launcher-update-priority`, erreur), boîtes de dialogue
(information, succès, question, avertissement, erreur, action irréversible avec option), menu de la zone
de notification.
* **Fenêtre « Aide »** (`feedback-*`, bouton de la barre de titre) : choix du type de demande, avis noté,
+16
View File
@@ -137,6 +137,7 @@ internal sealed class PlayerScenes : ISceneSet
yield return new SceneInfo("install-dialog" + suffix, "Installation : choix du dossier");
yield return new SceneInfo("launcher-update" + suffix, "Mise à jour du launcher · téléchargement");
yield return new SceneInfo("launcher-update-verified" + suffix, "Mise à jour du launcher · version vérifiée");
yield return new SceneInfo("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire");
yield return new SceneInfo("launcher-update-error" + suffix, "Mise à jour du launcher · erreur");
foreach (var (id, title, _) in Dialogs) yield return new SceneInfo(id + suffix, title);
yield return new SceneInfo("tray-menu" + suffix, "Menu de la zone de notification");
@@ -290,6 +291,21 @@ internal sealed class PlayerScenes : ISceneSet
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
// Version prioritaire (correctif critique) : pastille « Prioritaire » et texte dédié
var priorityRelease = SampleData.CreateLauncherRelease(_fixture.CurrentVersion);
priorityRelease.PriorityVersion = priorityRelease.Version;
var priorityCheck = new LauncherUpdateCheck(_fixture.CurrentVersion, priorityRelease,
new Uri("https://jeux.exemple.test/launcher/GameLauncher.exe"));
await capture.ShootAsync("launcher-update-priority" + suffix, "Mise à jour du launcher · version prioritaire", () =>
{
_fixture.ApplyTheme(theme);
var viewModel = new LauncherUpdateViewModel(priorityCheck, automatic: true);
viewModel.Progress.Report(new OperationProgress("Téléchargement de la nouvelle version",
$"{Format.Bytes(25_800_000)} / {Format.Bytes(priorityRelease.Size)} • {Format.Speed(6_300_000)} • " +
$"{Format.Duration(TimeSpan.FromSeconds(2))} restantes", 67.5));
return Task.FromResult<Window>(new LauncherUpdateWindow { DataContext = viewModel });
}, CloseUpdateWindowAsync);
await capture.ShootAsync("launcher-update-error" + suffix, "Mise à jour du launcher · erreur", () =>
{
_fixture.ApplyTheme(theme);
+40 -68
View File
@@ -1,98 +1,70 @@
# Launcher 3.3.0
# Launcher 3.4.0
LAUNCHER - NOTES DE MISE À JOUR
Mode maintenance - Mise à jour du 10 novembre 2026
==================================================================
Mises à jour prioritaires du launcher - Mise à jour du 10 novembre 2026
=========================================================================
Nouvelle page « Maintenance » dans le menu d'administration : un bouton met tous les launchers en mode maintenance. Le launcher passe au rouge / orange, affiche « Maintenance en cours, merci de revenir plus tard » au centre et bloque tout, sauf la désinstallation des jeux et le menu d'administration.
Une version du launcher peut désormais être publiée comme prioritaire, pour un correctif critique (faille de sécurité…) : elle s'installe au démarrage même si vous avez désactivé la mise à jour automatique du launcher. Votre réglage n'est pas modifié : les versions normales suivantes ne s'installeront toujours pas toutes seules.
Le serveur est à redéployer et un bloc nginx est à ajouter ; le trousseau est à re-signer après la diffusion de cette version.
Aucune action sur le serveur. Le launcher se met à jour automatiquement.
------------------------------------------------------------------
EN BREF
------------------------------------------------------------------
- NOUVEAU : page « Maintenance » (activer, modifier le message,
terminer), permission maintenance.manage.
- NOUVEAU : launcher en rouge / orange, carte « Maintenance en
cours » au centre, bouton « MAINTENANCE ».
- NOUVEAU : tout bloqué chez les joueurs, sauf la désinstallation
des jeux et le menu d'administration.
- AMÉLIORÉ : bandeau au tableau de bord, ligne dans la page
« Signature », catégorie dans le journal d'audit.
- SÉCURITÉ : mode maintenance signé (autorisation « mode
maintenance » du trousseau) et vérifié par les launchers.
- SERVEUR : à redéployer ; bloc nginx maintenance.json à ajouter.
- QUALITÉ : 11 nouveaux tests automatisés (367 au total).
- NOUVEAU : version prioritaire, installée au démarrage même avec
la mise à jour automatique du launcher désactivée.
- NOUVEAU : pastille « Prioritaire » dans la fenêtre de mise à
jour, avertissement dans le bandeau et les paramètres tant
qu'elle n'est pas installée.
- SÉCURITÉ : l'indication « prioritaire » est signée, comme le
reste de la mise à jour.
- QUALITÉ : nouveaux tests automatisés.
------------------------------------------------------------------
PAGE « MAINTENANCE »
VERSION PRIORITAIRE
------------------------------------------------------------------
- État publié : en cours depuis quand, par qui ; ou terminée.
- Message facultatif pour les joueurs : 300 caractères, 6 lignes,
texte brut, aucun lien ; compteur, contrôle en direct, aperçu.
- « Activer la maintenance » : confirmation détaillée.
- « Modifier le message » : même heure de début.
- « Terminer la maintenance » : confirmation.
- Le launcher de l'administrateur bascule aussitôt.
- Tableau de bord : bandeau « Maintenance en cours » pour tous les
administrateurs.
------------------------------------------------------------------
CHEZ LES JOUEURS
------------------------------------------------------------------
- Couleurs rouge / orange, carte au centre avec le message et
l'heure de début, fondu au début et à la fin.
- Bloqués : installation, mises à jour (automatiques comprises),
vérification, réparation, localisation, lancement (raccourcis
compris), paramètres, bouton « Aide », site officiel.
- Toujours possibles : désinstallation, menu d'administration.
- Non interrompus : téléchargement commencé, partie en cours, mise
à jour du launcher lui-même.
- Lu au démarrage avant la recherche des mises à jour, puis toutes
les 2 minutes (1 minute pendant une maintenance).
- Hors ligne au démarrage : pas de maintenance (le jeu installé
reste jouable).
- Votre réglage « Mettre à jour le launcher automatiquement »
n'est jamais modifié : effet ponctuel, le temps d'installer le
correctif.
- « Plus tard » reste possible : nouvelle tentative au prochain
démarrage du launcher.
- Une erreur de téléchargement est retentée au démarrage suivant.
- Si la nouvelle version ne démarre pas, l'ancienne est remise en
place et le bouton « Vérifier les mises à jour » permet de
réessayer.
- Mise à jour automatique désactivée : le launcher vérifie
désormais au démarrage si une version prioritaire existe.
------------------------------------------------------------------
SÉCURITÉ
------------------------------------------------------------------
- Publication : permission maintenance.manage, requête signée et
document signé par la clé de la session, audit.
- Launchers : signature vérifiée selon contentSigning ; document
non signé, mal signé ou plus ancien que le dernier vu ignoré en
« require ».
- Aucun lien accepté dans le message ; titre fixe dans le code.
- Affichage pour les joueurs, pas une barrière de sécurité : les
serveurs du jeu restent seuls juges des connexions.
- L'indication « prioritaire » fait partie du contenu signé : un
serveur compromis ne peut ni imposer une mise à jour, ni
retirer la priorité d'un correctif.
- Seule une version signée et strictement supérieure peut être
installée, comme avant.
------------------------------------------------------------------
SERVEUR
------------------------------------------------------------------
- À redéployer : server/deploy/LISEZMOI.md, étapes 8 et 3 decies.
- nginx : bloc location = /catalog/maintenance.json
(server/deploy/nginx-catalog.conf).
- Aucun réglage à ajouter : fichier écrit à côté du trousseau.
- Ordre : serveur et nginx, launcher 3.3.0, puis trousseau re-signé
avec l'autorisation « mode maintenance ».
- Aucun changement.
------------------------------------------------------------------
DÉVELOPPEMENT
------------------------------------------------------------------
- Contrats : MaintenanceDocument, MaintenanceRules,
ContentKinds.Maintenance, SigningScopes.Maintenance,
AdminPermissions.MaintenanceManage, ApiRoutes.Maintenance.
- Serveur : module Maintenance, MaintenanceStore.
- Launcher : MaintenanceService (Core), Themes/Maintenance.xaml,
GameViewModel.IsMaintenance, LauncherViewModel.ApplyMaintenance.
- Menu : MaintenanceAdminModule, MaintenancePanelViewModel.
- Outil ui-snapshot : scènes main-maintenance, main-maintenance-
plain, admin-page-maintenance, admin-page-maintenance-edit.
- Documentation : docs/ADMIN-ARCHITECTURE.md (§ 11 septies).
- publish-launcher-update.bat --priority (version prioritaire,
confirmation demandée) et --clear-priority (ne reprend pas la
priorité déjà publiée).
- LauncherPublisher : options --priority, --clear-priority, --yes ;
ligne « Priorité » dans publish et verify.
- Core : LauncherRelease.PriorityVersion (reprise d'une
publication à l'autre), LauncherUpdateCheck.IsPriority.
- UiSnapshot : scène launcher-update-priority.
- Tests : LauncherPriorityTests.
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.
+58
View File
@@ -7,8 +7,41 @@ REM 3. (optionnel) l'envoie sur le VPS avec WinSCP
REM
REM Avant de lancer : augmentez <Version> dans src\GameLauncher\GameLauncher.csproj
REM et ecrivez les nouveautes dans tools\notes-launcher.txt.
REM
REM Parametres (facultatifs, depuis une invite de commandes) :
REM publish-launcher-update.bat version normale (double-clic)
REM publish-launcher-update.bat --priority version PRIORITAIRE (correctif critique) :
REM installee au demarrage MEME chez les joueurs qui ont desactive la mise a jour
REM automatique du launcher. Leur reglage (self-update.json) n'est pas modifie.
REM Reprise par les publications suivantes : un launcher plus ancien que cette
REM version se met a jour quoi qu'il arrive. Confirmation demandee.
REM publish-launcher-update.bat --clear-priority ne reprend pas la version prioritaire
REM deja publiee (priorite publiee par erreur).
REM ---------------------------------------------------------------------------
setlocal
set PRIORITY=
:args
if "%~1"=="" goto :argsdone
if /i "%~1"=="--priority" goto :opt_priority
if /i "%~1"=="--clear-priority" goto :opt_clear
if /i "%~1"=="--help" goto :usage
if "%~1"=="/?" goto :usage
echo Parametre inconnu : %~1
goto :usage
:opt_priority
if defined PRIORITY goto :conflict
set PRIORITY=--priority
REM shift /1 : %0 (donc %~dp0) reste inchange
shift /1
goto :args
:opt_clear
if defined PRIORITY goto :conflict
set PRIORITY=--clear-priority
shift /1
goto :args
:argsdone
set ROOT=%~dp0..
set KEY=%USERPROFILE%\.launcher-keys\launcher-signing.key
set OUTPUT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\Updates
@@ -16,6 +49,18 @@ REM Script WinSCP (laisser vide pour envoyer les fichiers a la main) :
set WINSCP_SCRIPT=C:\Users\solac\Documents\Game Laucher\GameLauncher_V2\Publish\publier-launcher-update.txt
set WINSCP="C:\Program Files (x86)\WinSCP\WinSCP.com"
if /i "%PRIORITY%"=="--priority" (
echo ***************************************************************************
echo Version PRIORITAIRE : installee au demarrage meme chez les joueurs qui ont
echo desactive la mise a jour automatique du launcher. Leur reglage ne change pas.
echo ***************************************************************************
echo.
)
if /i "%PRIORITY%"=="--clear-priority" (
echo Version normale : la version prioritaire deja publiee n'est PAS reprise.
echo.
)
echo === 1/3 Compilation du launcher ===
dotnet publish "%ROOT%\src\GameLauncher\GameLauncher.csproj" -c Release -p:PublishProfile=win-x64
if errorlevel 1 goto :error
@@ -28,6 +73,7 @@ dotnet run --project "%ROOT%\tools\LauncherPublisher\LauncherPublisher.csproj" -
--config "%ROOT%\src\GameLauncher\launcher.json" ^
--public-key "%ROOT%\src\GameLauncher\Security\update-signing.pub" ^
--notes "%~dp0notes-launcher.txt" ^
%PRIORITY% ^
--output "%OUTPUT%"
if errorlevel 1 goto :error
@@ -52,3 +98,15 @@ echo.
echo La publication a echoue.
pause
exit /b 1
:conflict
echo --priority et --clear-priority sont incompatibles.
exit /b 1
:usage
echo.
echo Utilisation : publish-launcher-update.bat [--priority ^| --clear-priority]
echo --priority version prioritaire (correctif critique) : installee meme si le
echo joueur a desactive la mise a jour automatique (reglage conserve)
echo --clear-priority ne reprend pas la version prioritaire deja publiee
exit /b 1