Compare commits

...
11 Commits
Author SHA1 Message Date
nicolas 5472522429 Notes de version du launcher 1.2.0
- Lancement du jeu depuis l'icône du Bureau, launcher fermé ou déjà
  ouvert (réglages Réduire / Fermer respectés).
- Réparation des raccourcis après un déplacement du launcher.
2026-10-24 17:31:49 +02:00
nicolas a443433a58 Version 1.2.0 2026-10-24 17:21:31 +02:00
nicolas 5a1985a2b5 Raccourci du Bureau : lance le jeu même si le launcher était fermé
- Lancé avec --game, le launcher vérifie les mises à jour du jeu
  (et les installe si la mise à jour automatique est activée), puis
  lance le jeu s'il est prêt, en respectant les réglages « Réduire le
  launcher pendant la partie » et « Fermer le launcher au lancement
  du jeu ». Sinon, le bandeau explique pourquoi.
- Nouvel argument interne --launch : après une mise à jour du launcher
  faite au démarrage depuis un raccourci, la nouvelle version lance le
  jeu. Après une mise à jour demandée depuis les paramètres, le jeu
  n'est pas lancé.
- README : raccourcis du Bureau et options --game / --updated.
2026-10-24 17:19:57 +02:00
nicolas 95238a2e84 README : raccourcis du Bureau et déplacement du launcher
- Nouvelle section « Raccourcis du Bureau et déplacement du launcher ».
- Réparation : mention du contrôle des raccourcis.
- Option --game : comportement si le launcher est déjà ouvert.
- Structure : ajout d'InstanceChannel.
2026-10-24 17:05:38 +02:00
nicolas 8797f9e3d3 Raccourci ouvert avec le launcher déjà ouvert : le jeu est lancé
- Lancé avec --game alors qu'une instance est déjà ouverte, le launcher
  lui transmet la demande puis se ferme. L'instance ouverte revient au
  premier plan sur ce jeu et le lance s'il est prêt et pas déjà lancé.
- Sans --game (ou sans réponse sous 2 s) : message « Le launcher est
  déjà ouvert » inchangé.
- Rien n'est lancé si une boîte de dialogue est ouverte.
- Canal fermé avant le redémarrage d'une mise à jour du launcher,
  puis recréé par la nouvelle version.
- Sécurité : canal nommé réservé à l'utilisateur courant
  (CurrentUserOnly), nom propre à la session et au SID, un seul
  message de 256 octets max en 2 s, identifiant de jeu uniquement
  (validé puis comparé à launcher.json, aucun chemin ni argument).
2026-10-24 17:05:25 +02:00
nicolas b6d364901a Réparer : contrôle des raccourcis + pas de double lancement du jeu
- Réparer vérifie les raccourcis du Bureau et l'entrée « Applications
  installées » : redirection vers l'emplacement actuel du launcher
  (sans question si l'ancien exe a disparu, avec confirmation s'il
  s'agit d'une autre copie encore présente). Fonctionne hors ligne.
  Le résultat est indiqué dans le bandeau.
- JOUER ne lance pas le jeu si son exécutable tourne déjà dans la
  session Windows (lancé hors du launcher ou avant un redémarrage).
- Nouvelle méthode LaunchFromShortcut() : lance le jeu s'il est prêt,
  attend la fin d'une vérification en cours, sinon explique dans le
  bandeau pourquoi le jeu n'est pas lancé.
2026-10-24 17:04:56 +02:00
nicolas 434471dfc1 Raccourcis du Bureau : recherche par argument --game et redirection
- Un raccourci du jeu est reconnu à ses arguments « --game <GameId> »,
  même s'il a été renommé (Bureau de l'utilisateur, 300 raccourcis max).
- Au démarrage, les raccourcis vers un launcher disparu (déplacé ou
  renommé) sont redirigés automatiquement vers le launcher actuel.
- La redirection conserve l'icône si le joueur l'a personnalisée.
- IShortcutService : LauncherPath, FindDesktopShortcuts(),
  RetargetDesktopShortcut().
- IRegistryService : RefreshLauncherPath() ajouté à l'interface.
- Nouveau record DesktopShortcut.
2026-10-24 17:04:46 +02:00
nicolas 6512110eb0 Version 1.5 du jeu 2026-10-24 16:44:59 +02:00
nicolas 1401d9909c Passage à la version 1.4.2 du jeu 2026-10-21 22:30:23 +02:00
nicolas 8fbb12e52e Ajout de l'argument --prune-objects qui permet de supprimer les fichiers du dossier objects\ qui ne sont plus référencés dans le manifest JSON. 2026-10-21 22:28:52 +02:00
fred 7e52faf831 Merge pull request 'Ajout d'un module de mise à jour automatique' (#1) from dev/1.1 into master
Reviewed-on: #1
Reviewed-by: Deville <fred@lanisys.fr>
2026-10-18 12:22:58 +02:00
11 changed files with 986 additions and 241 deletions

No files matched your search

+38 -4
View File
@@ -47,7 +47,8 @@ src/GameLauncher/
├─ ViewModels/ LauncherViewModel (jeux, thème actif, paramètres du launcher),
│ GameViewModel (machine à états d'un jeu),
│ LauncherSettingsViewModel / GameSettingsViewModel (les deux fenêtres de paramètres)
├─ Services/ ThemeManager, RegistryService, ShortcutService, GameScopedServices, interfaces
├─ Services/ ThemeManager, RegistryService, ShortcutService, InstanceChannel (raccourci → launcher
│ déjà ouvert), GameScopedServices, interfaces
└─ Platform/ DialogService, WindowService (implémentations WPF)
```
@@ -151,6 +152,39 @@ enregistrées localement.
Si le dossier a disparu ou est presque vide → réinstallation via l'archive ZIP dans le même
dossier. Sinon → vérification complète + téléchargement des seuls fichiers défectueux.
La réparation contrôle aussi les **raccourcis du Bureau** du jeu et l'entrée « Applications
installées » de Windows (voir « Raccourcis du Bureau » ci-dessous) : s'ils désignent un ancien
emplacement du launcher, ils sont redirigés vers le launcher actuel (même hors ligne).
### Raccourcis du Bureau et déplacement du launcher
Le raccourci créé à l'installation pointe vers **le launcher** (`GameLauncher.exe --game <GameId>`),
pas vers le jeu : les mises à jour sont ainsi toujours vérifiées. Un raccourci « du jeu » est
reconnu à ses arguments `--game <GameId>`, même si le joueur l'a renommé (Bureau de l'utilisateur).
| Situation | Comportement |
|---|---|
| Raccourci ouvert, launcher **fermé** | Le launcher s'ouvre sur ce jeu, vérifie les mises à jour (et les installe si la mise à jour automatique du jeu est activée), puis **lance le jeu** s'il est prêt, en appliquant le réglage *Lancement du jeu* (réduire / fermer le launcher). Sinon le bandeau explique pourquoi (mise à jour manuelle à faire, réparation, jeu non installé). Hors ligne, le jeu installé est lancé. |
| Launcher déplacé ou renommé, raccourci vers l'ancien emplacement (fichier disparu) | Au prochain démarrage du launcher depuis son nouvel emplacement : raccourci redirigé automatiquement, `LauncherPath` et bouton « Désinstaller » de Windows mis à jour |
| Raccourci vers un **autre exemplaire** du launcher, encore présent (copie) | Rien d'automatique ; **Réparer** propose de le rediriger (confirmation) |
| Icône personnalisée par le joueur | Conservée lors de la redirection |
| Raccourci ouvert alors que le launcher est **déjà ouvert** | La demande est transmise au launcher ouvert : il revient au premier plan sur ce jeu et **lance le jeu** s'il est prêt et pas déjà en cours d'exécution (sinon le bandeau explique pourquoi : mise à jour, réparation, opération en cours…). Rien n'est lancé si une boîte de dialogue est ouverte. |
| Launcher relancé **sans** `--game` alors qu'il est déjà ouvert | Message « Le launcher est déjà ouvert. » (inchangé) |
Tant que le launcher n'a pas été relancé depuis son nouvel emplacement, un raccourci vers l'ancien
emplacement ne fonctionne pas (Windows le signale, et le retrouve parfois tout seul). Le chemin du
launcher est enregistré dans le registre (`LauncherPath`, `UninstallString`) pour chaque jeu
installé et mis à jour à chaque démarrage. Les paramètres (`%AppData%`) ne dépendent pas de
l'emplacement du launcher ; un `launcher.json` posé à côté de l'exe doit être déplacé avec lui.
Lancer le jeu, avec ou sans raccourci, ne démarre pas une seconde copie du jeu si son exécutable
tourne déjà (lancé directement ou avant un redémarrage du launcher).
**Communication entre instances** : canal nommé local réservé à l'utilisateur courant
(`PipeOptions.CurrentUserOnly`, nom propre à la session et à l'utilisateur), une seule commande
bornée (256 octets, 2 s) contenant uniquement un identifiant de jeu, validé puis comparé aux jeux
de `launcher.json`. Aucun chemin ni argument n'est transmis. Le canal est fermé avant le
redémarrage du launcher lors de sa mise à jour, puis recréé par la nouvelle version.
### Localiser le jeu
Sélection d'un dossier : l'exécutable est recherché dans le dossier et ses sous-dossiers
(2 niveaux). Le jeu est enregistré dans le registre puis analysé : s'il diffère de la dernière
@@ -234,18 +268,18 @@ liste des jeux apparaît alors à gauche de la fenêtre (masquée s'il n'y a qu'
rien ne change pour un jeu déjà installé avec la version mono-jeu.
* Les jeux sont vérifiés en parallèle ; une pastille sur la vignette signale une mise à jour,
une opération en cours ou un problème. Un message concernant un jeu sélectionne ce jeu.
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu).
* Raccourci du Bureau : `GameLauncher.exe --game <GameId>` (ouvre le launcher sur ce jeu et le lance s'il est prêt).
* Fermer le launcher pendant des téléchargements demande une seule confirmation pour tous les jeux.
### Options de la ligne de commande
| Option | Rôle |
|---|---|
| `--game <GameId>` | Ouvre le launcher sur ce jeu |
| `--game <GameId>` | Ouvre le launcher sur ce jeu, vérifie les mises à jour puis lance le jeu s'il est prêt ; si le launcher est déjà ouvert, lui demande de lancer ce jeu (voir « Raccourcis du Bureau ») |
| `--uninstall [GameId]` | Désinstallation (utilisée par Windows) |
| `--config <fichier>` | Utilise un autre `launcher.json` (tests) |
| `--capture <dossier>` | Enregistre une capture PNG par jeu (+ ses paramètres, + paramètres du launcher), sans rien installer, puis quitte |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7) |
| `--updated <pid> <événement> <version>` | Interne : démarrage de la nouvelle version après une mise à jour du launcher (§7). Le jeu de `--game` n'est alors lancé que si `--launch` est aussi présent (mise à jour faite au démarrage depuis un raccourci), jamais après une mise à jour demandée depuis les paramètres |
## 6. Bon à savoir
+109 -6
View File
@@ -1,6 +1,8 @@
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media;
using System.Windows.Threading;
using GameLauncher.Core.Models;
@@ -17,16 +19,22 @@ namespace GameLauncher;
/// <summary>
/// Arguments de la ligne de commande :
/// --uninstall [GameId] désinstallation (bouton « Désinstaller » de Windows)
/// --game GameId ouvre le launcher sur ce jeu (raccourcis du Bureau)
/// --game GameId raccourci du Bureau : ouvre le launcher sur ce jeu, vérifie les mises à jour
/// puis lance le jeu s'il est prêt (réglages « Réduire » / « Fermer le launcher »
/// respectés) ; si le launcher est déjà ouvert, la demande lui est transmise
/// --config chemin utilise un autre launcher.json (tests)
/// --capture dossier enregistre une capture PNG par jeu puis quitte (documentation)
/// --updated pid nom version (interne) démarrage d'une nouvelle version après mise à jour du launcher
/// --launch (interne, avec --updated) le jeu de --game doit être lancé : mise à jour du
/// launcher faite au démarrage depuis un raccourci
/// </summary>
public partial class App : Application, ILauncherRestartHost
{
private Mutex? _singleInstance;
private bool _ownsMutex;
private string? _mutexName;
private InstanceChannel? _instanceChannel;
private LauncherCatalog? _catalog;
private CommandLine? _args;
private LauncherViewModel? _launcher;
private Views.MainWindow? _mainWindow;
@@ -68,6 +76,7 @@ public partial class App : Application, ILauncherRestartHost
}
Log.Init(Path.Combine(catalog.DataDirectory, "logs"));
_catalog = catalog;
_launcherPath = Environment.ProcessPath ?? Path.Combine(AppContext.BaseDirectory, "GameLauncher.exe");
_version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(1, 0, 0);
@@ -90,11 +99,25 @@ public partial class App : Application, ILauncherRestartHost
_mutexName = catalog.SingleInstanceName;
if (!AcquireSingleInstance(args.UpdatedFromPid is null ? TimeSpan.Zero : TimeSpan.FromSeconds(15)))
{
if (args.UpdatedFromPid is null)
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
else
if (args.UpdatedFromPid is not null)
{
Log.Warn("Mise à jour du launcher : une autre instance a été ouverte entre-temps.");
Shutdown(args.UpdatedFromPid is null ? 0 : 4);
Shutdown(4);
return;
}
// Raccourci du Bureau (--game) alors que le launcher est déjà ouvert : la demande lui est
// transmise (il lance le jeu s'il est prêt et pas déjà lancé), puis cette instance se ferme.
// Sans argument --game (ou s'il ne répond pas) : simple message, comme auparavant.
if (!string.IsNullOrWhiteSpace(args.GameId) && !args.Uninstall &&
await InstanceChannel.TryRequestLaunchAsync(catalog.SingleInstanceName, args.GameId, TimeSpan.FromSeconds(2)))
{
Shutdown(0);
return;
}
ModernDialog.ShowMessage(null, catalog.LauncherName, "Le launcher est déjà ouvert.", DialogKind.Info);
Shutdown(0);
return;
}
}
@@ -128,6 +151,13 @@ public partial class App : Application, ILauncherRestartHost
return;
}
// Écoute des demandes des raccourcis du Bureau ouverts pendant que le launcher est affiché.
if (args.CaptureDirectory is null && _mutexName is not null && _ownsMutex)
{
_instanceChannel = new InstanceChannel(_mutexName, OnLaunchRequestedByAnotherInstance);
_instanceChannel.Start();
}
// Après une mise à jour : prévenir l'ancienne instance que tout va bien
if (args.UpdatedFromPid is { } previousPid)
_ = ConfirmLauncherUpdatedAsync(previousPid, args.UpdatedEventName);
@@ -140,7 +170,7 @@ public partial class App : Application, ILauncherRestartHost
if (args.CaptureDirectory is not null)
await CaptureAsync(window, args.CaptureDirectory);
else
await _launcher!.InitializeAsync(args.Uninstall, args.UninstallGameId);
await _launcher!.InitializeAsync(args.Uninstall, args.UninstallGameId, args.LaunchGameId);
}
catch (Exception ex)
{
@@ -294,6 +324,8 @@ public partial class App : Application, ILauncherRestartHost
protected override void OnExit(ExitEventArgs e)
{
_instanceChannel?.Dispose();
_instanceChannel = null;
_launcher?.Dispose();
_http?.Dispose();
ReleaseSingleInstance();
@@ -321,6 +353,55 @@ public partial class App : Application, ILauncherRestartHost
return _ownsMutex;
}
/// <summary>
/// Demande transmise par une autre instance (raccourci du Bureau). Appelé hors du thread de
/// l'interface : la demande y est simplement planifiée, la réponse est immédiate.
/// </summary>
/// <returns>Vrai si le jeu fait partie de ce launcher.</returns>
private bool OnLaunchRequestedByAnotherInstance(string gameId)
{
var known = _catalog?.Find(gameId) is not null;
Dispatcher.InvokeAsync(() => HandleLaunchRequest(gameId));
return known;
}
private void HandleLaunchRequest(string gameId)
{
try
{
Log.Info($"Raccourci ouvert alors que le launcher est déjà affiché : {gameId}");
if (_launcher is null || _mainWindow is null) return;
if (_updates?.IsInstalling == true) return; // le launcher redémarre dans sa nouvelle version
// Une boîte de dialogue est ouverte (confirmation, choix de dossier…) : on ne lance rien
// derrière elle, la fenêtre revient simplement au premier plan.
if (!IsMainWindowInteractive())
{
_mainWindow.Activate();
Log.Info("Une boîte de dialogue est ouverte : le jeu n'est pas lancé.");
return;
}
_launcher.LaunchFromShortcut(gameId);
}
catch (Exception ex)
{
Log.Error("Demande de lancement depuis un raccourci", ex);
}
}
/// <summary>Fenêtre principale visible et non bloquée par une fenêtre modale (WPF ou Windows).</summary>
private bool IsMainWindowInteractive()
{
if (_mainWindow is not { IsVisible: true }) return false;
var handle = new WindowInteropHelper(_mainWindow).Handle;
return handle != IntPtr.Zero && IsWindowEnabled(handle);
}
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindowEnabled(IntPtr hWnd);
private void ReleaseSingleInstance()
{
if (_singleInstance is null) return;
@@ -397,6 +478,7 @@ public partial class App : Application, ILauncherRestartHost
void ILauncherRestartHost.ReleaseForRestart()
{
_mainWindow?.Hide();
_instanceChannel?.Stop(); // la nouvelle version recréera le canal
ReleaseSingleInstance();
}
@@ -404,6 +486,8 @@ public partial class App : Application, ILauncherRestartHost
{
if (_mutexName is not null && !AcquireSingleInstance(TimeSpan.FromSeconds(2)))
Log.Warn("Le verrou d'instance unique n'a pas pu être repris après l'échec de la mise à jour.");
else
_instanceChannel?.Start();
if (_mainWindow is not null)
{
_mainWindow.Show();
@@ -422,6 +506,11 @@ public partial class App : Application, ILauncherRestartHost
var arguments = new List<string>();
var gameId = _launcher?.SelectedGame?.GameId ?? _args?.GameId;
if (!string.IsNullOrWhiteSpace(gameId)) arguments.AddRange(new[] { "--game", gameId });
// Mise à jour du launcher faite au démarrage (fenêtre principale pas encore créée) depuis un
// raccourci : la nouvelle version lancera le jeu. Après une mise à jour demandée depuis les
// paramètres, elle affiche seulement le jeu sélectionné.
if (_launcher is null && _args?.LaunchGameId is not null) arguments.Add("--launch");
if (_args?.ConfigPath is { Length: > 0 } config) arguments.AddRange(new[] { "--config", Path.GetFullPath(config) });
return arguments;
}
@@ -449,6 +538,7 @@ public partial class App : Application, ILauncherRestartHost
{
public CommandLine(IReadOnlyList<string> args)
{
var launchAfterUpdate = false;
for (var i = 0; i < args.Count; i++)
{
var arg = args[i];
@@ -474,13 +564,26 @@ public partial class App : Application, ILauncherRestartHost
UpdatedEventName = Next();
UpdatedExpectedVersion = Next();
break;
case "--launch":
launchAfterUpdate = true;
break;
}
}
// --game lance le jeu (raccourci du Bureau). Après une mise à jour du launcher (--updated),
// seulement si l'ancienne version le demande explicitement (--launch) : une mise à jour
// lancée depuis les paramètres ne doit pas démarrer le jeu.
if (!string.IsNullOrWhiteSpace(GameId) && !Uninstall && CaptureDirectory is null &&
(UpdatedFromPid is null || launchAfterUpdate))
LaunchGameId = GameId;
}
public bool Uninstall { get; }
public string? UninstallGameId { get; }
public string? GameId { get; }
/// <summary>Jeu à lancer une fois le launcher prêt (raccourci du Bureau), sinon null.</summary>
public string? LaunchGameId { get; }
public string? ConfigPath { get; }
public string? CaptureDirectory { get; }
public int? UpdatedFromPid { get; }
+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>1.1.3</Version>
<Version>1.2.0</Version>
<Authors>Lanisys</Authors>
<Company>LanisysGames</Company>
<Copyright>© LanisysGames</Copyright>
@@ -0,0 +1,320 @@
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Security.Principal;
using System.Text;
using GameLauncher.Core.Utils;
using Microsoft.Win32.SafeHandles;
namespace GameLauncher.Services;
/// <summary>
/// Canal de communication entre deux instances du launcher (canal nommé local).
///
/// Le launcher déjà ouvert écoute. Une seconde instance lancée avec <c>--game &lt;GameId&gt;</c>
/// (raccourci du Bureau) lui transmet la demande de lancement du jeu, puis se ferme.
///
/// Sécurité :
/// • <see cref="PipeOptions.CurrentUserOnly"/> des deux côtés : seul l'utilisateur courant peut
/// se connecter au launcher, et la seconde instance refuse un canal créé par un autre utilisateur ;
/// • nom propre à la session Windows et à l'utilisateur (SID) ;
/// • une seule commande, de taille bornée (256 octets) et limitée dans le temps (2 s) ;
/// • seul un identifiant de jeu est transmis, jamais un chemin ni des arguments : le launcher
/// ne lance que les jeux déclarés dans son launcher.json.
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class InstanceChannel : IDisposable
{
private const string LaunchCommand = "LAUNCH";
private const string ReplyAccepted = "OK";
private const string ReplyUnknownGame = "UNKNOWN";
private const string ReplyError = "ERROR";
private const int MaxMessageBytes = 256;
private const int MaxGameIdLength = 64;
private static readonly TimeSpan IoTimeout = TimeSpan.FromSeconds(2);
private readonly string _pipeName;
private readonly Func<string, bool> _onLaunchRequest;
private readonly object _sync = new();
private CancellationTokenSource? _cts;
private NamedPipeServerStream? _server;
/// <param name="instanceName">Nom du verrou d'instance unique (ex. <c>Local\Éditeur.Launcher</c>).</param>
/// <param name="onLaunchRequest">
/// Appelé (hors du thread de l'interface) avec un identifiant de jeu au format valide.
/// Retourne vrai si le jeu est connu de ce launcher.
/// </param>
public InstanceChannel(string instanceName, Func<string, bool> onLaunchRequest)
{
_pipeName = BuildPipeName(instanceName);
_onLaunchRequest = onLaunchRequest;
}
// ------------------------------------------------------------------ Côté launcher ouvert (serveur)
/// <summary>Commence à écouter les demandes des autres instances.</summary>
public void Start()
{
lock (_sync)
{
if (_cts is not null) return;
_cts = new CancellationTokenSource();
var cts = _cts;
_ = Task.Run(() => ListenAsync(cts));
}
}
/// <summary>
/// Arrête l'écoute et ferme le canal (ex. avant le redémarrage du launcher dans sa nouvelle
/// version, qui doit pouvoir recréer le canal).
/// </summary>
public void Stop()
{
NamedPipeServerStream? server;
lock (_sync)
{
_cts?.Cancel();
_cts = null;
server = _server;
_server = null;
}
try
{
server?.Dispose(); // interrompt l'attente de connexion en cours
}
catch (Exception ex)
{
Log.Warn($"Fermeture du canal entre instances : {ex.Message}");
}
}
public void Dispose() => Stop();
private async Task ListenAsync(CancellationTokenSource cts)
{
var token = cts.Token;
var failures = 0;
try
{
while (!token.IsCancellationRequested)
{
NamedPipeServerStream server;
try
{
// Une seule connexion à la fois ; si le nom est déjà pris (autre programme), la création échoue.
server = new NamedPipeServerStream(_pipeName, PipeDirection.InOut, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
failures = 0;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Le canal précédent est peut-être encore en cours de fermeture : quelques nouvelles tentatives.
if (++failures >= 5)
{
Log.Warn($"Canal entre instances indisponible : {ex.Message}. " +
"Un raccourci du Bureau ne pourra pas lancer le jeu tant que ce launcher est ouvert.");
return;
}
await Task.Delay(TimeSpan.FromMilliseconds(500), token);
continue;
}
lock (_sync)
{
if (token.IsCancellationRequested)
{
server.Dispose();
return;
}
_server = server;
}
try
{
await server.WaitForConnectionAsync(token);
await HandleClientAsync(server, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
return;
}
catch (Exception ex) when (ex is IOException or ObjectDisposedException or OperationCanceledException
or InvalidOperationException)
{
// Client déconnecté, délai dépassé ou canal fermé par Stop() : on attend le suivant.
if (!token.IsCancellationRequested) Log.Warn($"Canal entre instances : {ex.Message}");
}
finally
{
lock (_sync)
{
if (ReferenceEquals(_server, server)) _server = null;
}
try { server.Dispose(); }
catch (Exception) { /* déjà fermé */ }
}
}
}
catch (OperationCanceledException)
{
// arrêt demandé
}
catch (Exception ex)
{
Log.Error("Canal entre instances interrompu", ex);
}
finally
{
cts.Dispose();
}
}
private async Task HandleClientAsync(NamedPipeServerStream pipe, CancellationToken token)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(token);
timeout.CancelAfter(IoTimeout);
var line = await ReadLineAsync(pipe, timeout.Token);
string reply;
if (line is not null && TryParseLaunch(line, out var gameId))
{
try
{
reply = _onLaunchRequest(gameId) ? ReplyAccepted : ReplyUnknownGame;
}
catch (Exception ex)
{
Log.Error("Demande de lancement transmise par une autre instance", ex);
reply = ReplyError;
}
}
else
{
Log.Warn("Canal entre instances : requête invalide ignorée.");
reply = ReplyError;
}
await pipe.WriteAsync(Encoding.UTF8.GetBytes(reply + "\n"), timeout.Token);
await pipe.FlushAsync(timeout.Token);
}
// ------------------------------------------------------------------ Côté nouvelle instance (client)
/// <summary>
/// Transmet au launcher déjà ouvert la demande de lancement de <paramref name="gameId"/>.
/// </summary>
/// <returns>
/// Vrai si le launcher ouvert a pris la demande en charge (la nouvelle instance peut se fermer
/// sans rien afficher) ; faux s'il n'a pas répondu (ancienne version du launcher, launcher en
/// cours de démarrage ou de mise à jour…).
/// </returns>
public static async Task<bool> TryRequestLaunchAsync(string instanceName, string gameId, TimeSpan connectTimeout)
{
if (!IsValidGameId(gameId))
{
Log.Warn("Argument --game invalide : demande non transmise au launcher ouvert.");
return false;
}
try
{
await using var client = new NamedPipeClientStream(".", BuildPipeName(instanceName), PipeDirection.InOut,
PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
using var cts = new CancellationTokenSource(connectTimeout + IoTimeout);
await client.ConnectAsync((int)connectTimeout.TotalMilliseconds, cts.Token);
// Cette instance vient d'être lancée par l'utilisateur : elle peut céder le premier plan
// au launcher déjà ouvert (sinon Windows le laisserait clignoter dans la barre des tâches).
GrantForegroundTo(client);
await client.WriteAsync(Encoding.UTF8.GetBytes($"{LaunchCommand} {gameId}\n"), cts.Token);
await client.FlushAsync(cts.Token);
var reply = await ReadLineAsync(client, cts.Token);
Log.Info($"Demande de lancement de {gameId} transmise au launcher déjà ouvert (réponse : {reply ?? "aucune"}).");
return reply is ReplyAccepted or ReplyUnknownGame;
}
catch (Exception ex) when (ex is TimeoutException or IOException or UnauthorizedAccessException
or OperationCanceledException or InvalidOperationException)
{
// UnauthorizedAccessException : canal créé par un autre utilisateur (refusé par CurrentUserOnly).
Log.Warn($"Le launcher déjà ouvert n'a pas répondu : {ex.Message}");
return false;
}
}
// ------------------------------------------------------------------ Protocole
private static async Task<string?> ReadLineAsync(PipeStream pipe, CancellationToken ct)
{
var buffer = new byte[MaxMessageBytes];
var count = 0;
while (count < buffer.Length)
{
var read = await pipe.ReadAsync(buffer.AsMemory(count, buffer.Length - count), ct);
if (read == 0) break;
count += read;
var newLine = Array.IndexOf(buffer, (byte)'\n', 0, count);
if (newLine >= 0) return Encoding.UTF8.GetString(buffer, 0, newLine).TrimEnd('\r');
}
return null; // message trop long ou incomplet
}
private static bool TryParseLaunch(string line, out string gameId)
{
gameId = "";
var prefix = LaunchCommand + " ";
if (!line.StartsWith(prefix, StringComparison.Ordinal)) return false;
var candidate = line[prefix.Length..];
if (!IsValidGameId(candidate)) return false;
gameId = candidate;
return true;
}
/// <summary>Mêmes règles que launcher.json (identifiant simple, sans espaces), plus une longueur bornée.</summary>
private static bool IsValidGameId(string? gameId) =>
!string.IsNullOrEmpty(gameId) &&
gameId.Length <= MaxGameIdLength &&
!gameId.Any(c => char.IsWhiteSpace(c) || char.IsControl(c)) &&
gameId.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
/// <summary>Nom du canal : nom du verrou d'instance unique + session Windows + SID de l'utilisateur.</summary>
private static string BuildPipeName(string instanceName)
{
var name = instanceName.StartsWith(@"Local\", StringComparison.OrdinalIgnoreCase) ? instanceName[6..] : instanceName;
var safe = new string(name.Select(c => char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_' ? c : '_').ToArray());
string user;
using (var identity = WindowsIdentity.GetCurrent())
user = identity.User?.Value ?? Environment.UserName;
int session;
using (var process = Process.GetCurrentProcess())
session = process.SessionId;
return $"{safe}.Instance.{session}.{user}";
}
private static void GrantForegroundTo(NamedPipeClientStream client)
{
try
{
if (GetNamedPipeServerProcessId(client.SafePipeHandle, out var serverPid))
AllowSetForegroundWindow(serverPid);
}
catch (Exception ex)
{
Log.Warn($"Passage au premier plan du launcher ouvert : {ex.Message}");
}
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetNamedPipeServerProcessId(SafePipeHandle pipe, out uint serverProcessId);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(uint processId);
}
+33 -1
View File
@@ -52,14 +52,46 @@ public interface IRegistryService
{
GameRegistration? Read();
void Write(GameRegistration registration, long estimatedSizeBytes);
/// <summary>
/// Emplacement du launcher (LauncherPath, bouton « Désinstaller » de Windows) mis à jour s'il a
/// été déplacé. Ne crée rien si le jeu n'est pas enregistré.
/// </summary>
void RefreshLauncherPath();
void Delete();
}
/// <summary>
/// Raccourci du Bureau qui ouvre ce jeu dans le launcher (arguments « --game &lt;GameId&gt; »),
/// quel que soit son nom.
/// </summary>
/// <param name="FilePath">Fichier .lnk.</param>
/// <param name="TargetPath">Exécutable visé par le raccourci.</param>
/// <param name="PointsToThisLauncher">Le raccourci vise le launcher en cours d'exécution.</param>
/// <param name="TargetExists">L'exécutable visé existe encore (sinon : launcher déplacé, renommé ou supprimé).</param>
public sealed record DesktopShortcut(string FilePath, string TargetPath, bool PointsToThisLauncher, bool TargetExists)
{
public string Name => System.IO.Path.GetFileNameWithoutExtension(FilePath);
}
public interface IShortcutService
{
/// <summary>Emplacement du launcher en cours d'exécution.</summary>
string LauncherPath { get; }
void CreateDesktopShortcut();
/// <summary>Raccourci existant dont la cible a disparu (launcher renommé ou déplacé) : recréé vers le launcher actuel.</summary>
/// <summary>
/// Démarrage du launcher : les raccourcis de ce jeu dont la cible a disparu (launcher renommé
/// ou déplacé) sont redirigés vers le launcher actuel, sans rien demander.
/// </summary>
void RefreshDesktopShortcut();
/// <summary>Raccourcis du Bureau (dossier de l'utilisateur) qui ouvrent ce jeu.</summary>
IReadOnlyList<DesktopShortcut> FindDesktopShortcuts();
/// <summary>Redirige le raccourci vers le launcher actuel. Faux en cas d'échec (journalisé).</summary>
bool RetargetDesktopShortcut(DesktopShortcut shortcut);
void RemoveDesktopShortcut();
}
+182 -48
View File
@@ -9,91 +9,225 @@ namespace GameLauncher.Services;
/// <summary>
/// Raccourci bureau vers le LAUNCHER (et non vers le jeu) : ainsi les mises à jour
/// sont toujours vérifiées avant de jouer.
///
/// Un raccourci « de ce jeu » se reconnaît à ses arguments (<c>--game &lt;GameId&gt;</c>), pas à son
/// nom : il est retrouvé même si le joueur l'a renommé. Si le launcher est déplacé ou renommé,
/// ses raccourcis sont redirigés vers le nouvel emplacement (au démarrage si l'ancien exécutable
/// a disparu, ou depuis « Réparer »).
/// </summary>
[SupportedOSPlatform("windows")]
public sealed class ShortcutService : IShortcutService
{
/// <summary>Nombre maximal de raccourcis examinés sur le Bureau (borne le temps de recherche).</summary>
private const int MaxScannedShortcuts = 300;
private readonly LauncherConfig _config;
private readonly string _launcherPath;
public ShortcutService(LauncherConfig config, string launcherPath)
{
_config = config;
_launcherPath = launcherPath;
_launcherPath = Path.GetFullPath(launcherPath);
}
private string ShortcutPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory),
PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
public string LauncherPath => _launcherPath;
private string Arguments => $"--game {_config.GameId}"; // ouvre le launcher sur ce jeu
private static string DesktopDirectory => Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory);
private string ShortcutPath => Path.Combine(DesktopDirectory, PathHelper.SanitizeFolderName(_config.GameName) + ".lnk");
public void CreateDesktopShortcut()
{
object? shell = null;
object? link = null;
try
{
var type = Type.GetTypeFromProgID("WScript.Shell")
?? throw new InvalidOperationException("WScript.Shell indisponible.");
shell = Activator.CreateInstance(type)!;
dynamic dynShell = shell;
link = dynShell.CreateShortcut(ShortcutPath);
dynamic dynLink = link!;
dynLink.TargetPath = _launcherPath;
dynLink.Arguments = $"--game {_config.GameId}"; // ouvre le launcher sur ce jeu
dynLink.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
dynLink.IconLocation = _launcherPath + ",0";
dynLink.Description = _config.GameName;
dynLink.Save();
WithShell(shell => WithLink(shell, ShortcutPath, link =>
{
link.TargetPath = _launcherPath;
link.Arguments = Arguments;
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
link.IconLocation = _launcherPath + ",0";
link.Description = _config.GameName;
link.Save();
return true;
}));
Log.Info($"Raccourci créé : {ShortcutPath}");
}
catch (Exception ex)
{
Log.Error("Création du raccourci impossible", ex);
}
finally
{
if (link is not null && Marshal.IsComObject(link)) Marshal.FinalReleaseComObject(link);
if (shell is not null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
}
}
public void RefreshDesktopShortcut()
{
if (!File.Exists(ShortcutPath)) return;
foreach (var shortcut in FindDesktopShortcuts())
{
// Raccourci valide, ou vers un autre exemplaire du launcher encore présent : on n'y touche
// pas ici (le bouton « Réparer » propose de le rediriger, après confirmation).
if (shortcut.PointsToThisLauncher || shortcut.TargetExists) continue;
Log.Info($"Raccourci « {shortcut.Name} » vers un launcher disparu ({shortcut.TargetPath}) : mise à jour vers {_launcherPath}");
RetargetDesktopShortcut(shortcut);
}
}
public IReadOnlyList<DesktopShortcut> FindDesktopShortcuts()
{
var found = new List<DesktopShortcut>();
List<string> files;
try
{
var desktop = DesktopDirectory;
if (string.IsNullOrEmpty(desktop) || !Directory.Exists(desktop)) return found;
files = Directory.EnumerateFiles(desktop, "*.lnk", SearchOption.TopDirectoryOnly)
.Take(MaxScannedShortcuts)
.OrderBy(file => !SamePath(file, ShortcutPath)) // celui créé par le launcher en premier
.ToList();
}
catch (Exception ex)
{
Log.Warn($"Lecture du Bureau impossible : {ex.Message}");
return found;
}
if (files.Count == 0) return found;
try
{
WithShell(shell =>
{
foreach (var file in files)
{
string? target;
string? arguments;
try
{
(target, arguments) = WithLink(shell, file, link => ((string?)link.TargetPath, (string?)link.Arguments));
}
catch (Exception ex)
{
Log.Warn($"Lecture du raccourci impossible ({file}) : {ex.Message}");
continue;
}
if (string.IsNullOrWhiteSpace(target) || !IsForThisGame(arguments)) continue;
found.Add(new DesktopShortcut(file, target, SamePath(target, _launcherPath), File.Exists(target)));
}
return true;
});
}
catch (Exception ex)
{
Log.Warn($"Recherche des raccourcis du Bureau impossible : {ex.Message}");
}
return found;
}
public bool RetargetDesktopShortcut(DesktopShortcut shortcut)
{
try
{
WithShell(shell => WithLink(shell, shortcut.FilePath, link =>
{
// Icône personnalisée par le joueur : conservée. Icône de l'ancien launcher : remplacée.
var iconFile = IconFile((string?)link.IconLocation);
var keepIcon = iconFile is not null && File.Exists(iconFile) && !SamePath(iconFile, shortcut.TargetPath);
link.TargetPath = _launcherPath;
link.Arguments = Arguments;
link.WorkingDirectory = Path.GetDirectoryName(_launcherPath);
if (!keepIcon) link.IconLocation = _launcherPath + ",0";
link.Save();
return true;
}));
Log.Info($"Raccourci « {shortcut.Name} » redirigé : {shortcut.TargetPath} → {_launcherPath}");
return true;
}
catch (Exception ex)
{
Log.Error($"Mise à jour du raccourci impossible ({shortcut.FilePath})", ex);
return false;
}
}
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
// ------------------------------------------------------------------ Utilitaires
/// <summary>Arguments du raccourci contenant « --game &lt;GameId&gt; » (guillemets tolérés).</summary>
private bool IsForThisGame(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments)) return false;
var tokens = arguments.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim('"'))
.ToArray();
for (var i = 0; i < tokens.Length - 1; i++)
{
if (string.Equals(tokens[i], "--game", StringComparison.OrdinalIgnoreCase) &&
string.Equals(tokens[i + 1], _config.GameId, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <summary>« C:\…\GameLauncher.exe,0 » → « C:\…\GameLauncher.exe ».</summary>
private static string? IconFile(string? iconLocation)
{
if (string.IsNullOrWhiteSpace(iconLocation)) return null;
var comma = iconLocation.LastIndexOf(',');
var file = (comma > 0 ? iconLocation[..comma] : iconLocation).Trim().Trim('"');
return file.Length == 0 ? null : Environment.ExpandEnvironmentVariables(file);
}
private static bool SamePath(string? a, string? b)
{
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b)) return false;
try
{
return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
}
catch (Exception)
{
return false; // chemin invalide
}
}
/// <summary>Objet COM WScript.Shell, libéré après usage.</summary>
private static T WithShell<T>(Func<object, T> action)
{
object? shell = null;
object? link = null;
string? target;
try
{
var type = Type.GetTypeFromProgID("WScript.Shell")
?? throw new InvalidOperationException("WScript.Shell indisponible.");
shell = Activator.CreateInstance(type)!;
dynamic dynShell = shell;
link = dynShell.CreateShortcut(ShortcutPath); // ouvre le raccourci existant
dynamic dynLink = link!;
target = (string?)dynLink.TargetPath;
}
catch (Exception ex)
{
Log.Warn($"Lecture du raccourci impossible ({ShortcutPath}) : {ex.Message}");
return;
shell = Activator.CreateInstance(type)
?? throw new InvalidOperationException("WScript.Shell indisponible.");
return action(shell);
}
finally
{
if (link is not null && Marshal.IsComObject(link)) Marshal.FinalReleaseComObject(link);
if (shell is not null && Marshal.IsComObject(shell)) Marshal.FinalReleaseComObject(shell);
Release(shell);
}
// Raccourci valide (ou vers un autre programme encore présent) : on n'y touche pas.
if (string.IsNullOrWhiteSpace(target) ||
string.Equals(target, _launcherPath, StringComparison.OrdinalIgnoreCase) ||
File.Exists(target))
return;
Log.Info($"Raccourci vers un launcher disparu ({target}) : mise à jour vers {_launcherPath}");
CreateDesktopShortcut();
}
public void RemoveDesktopShortcut() => FileUtil.TryDeleteFile(ShortcutPath);
/// <summary>Ouvre (ou prépare, s'il n'existe pas) un raccourci .lnk, libéré après usage.</summary>
private static T WithLink<T>(object shell, string path, Func<dynamic, T> action)
{
object? link = null;
try
{
link = ((dynamic)shell).CreateShortcut(path);
return action(link!);
}
finally
{
Release(link);
}
}
private static void Release(object? com)
{
if (com is not null && Marshal.IsComObject(com)) Marshal.FinalReleaseComObject(com);
}
}
+171 -1
View File
@@ -61,6 +61,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
private bool _needsRepair;
private string? _lastInstallDirectory;
private int _bannerVersion;
private bool _launchWhenReady;
public GameViewModel(LauncherConfig config, LauncherSettings settings, string settingsPath,
LauncherPreferences preferences, GameInstaller installer, ManifestClient manifestClient,
@@ -168,6 +169,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
OnPropertyChanged(nameof(ShowRailBadge));
OnPropertyChanged(nameof(RailToolTip));
RefreshCommands();
// Lancement demandé par un raccourci pendant une vérification : traité à la fin de celle-ci.
if (_launchWhenReady && value != LauncherState.Checking)
{
_launchWhenReady = false;
_ui.Post(_ => LaunchFromShortcut(), null);
}
}
}
@@ -734,6 +742,14 @@ public sealed class GameViewModel : ObservableObject, IDisposable
var dir = InstallPath;
if (dir is null) return;
// Raccourcis du Bureau et entrée « Applications installées » : redirigés vers ce launcher
// s'il a été déplacé. Fait en premier : instantané, et utile même hors ligne.
var linksRepaired = RepairLauncherLinks();
void ReportLinksOnly()
{
if (linksRepaired is not null) ShowBanner("Raccourci du Bureau mis à jour", linksRepaired, DialogKind.Success);
}
var manifest = await TryFetchManifestAsync();
if (manifest is null)
{
@@ -741,6 +757,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_dialogs.Show("Serveur injoignable",
"La réparation nécessite une connexion au serveur. Vérifiez votre connexion Internet puis réessayez.",
DialogKind.Error);
ReportLinksOnly();
return;
}
@@ -749,7 +766,10 @@ public sealed class GameViewModel : ObservableObject, IDisposable
if (!_dialogs.Confirm("Réparer le jeu",
$"Le jeu va être téléchargé à nouveau dans :\n{dir}\n\nTaille du téléchargement : {Format.Bytes(manifest.Archive.Size)}",
"Réparer", "Annuler", DialogKind.Question))
{
ReportLinksOnly();
return;
}
var root = Path.GetPathRoot(dir);
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
@@ -757,11 +777,13 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_dialogs.Show("Lecteur introuvable",
$"Le lecteur « {root} » n'est pas disponible. Branchez-le, localisez le jeu à un autre endroit ou oubliez cette installation.",
DialogKind.Warning);
ReportLinksOnly();
return;
}
if (!PathHelper.TryEnsureWritable(dir, out var error))
{
_dialogs.Show("Réparation impossible", error!, DialogKind.Error);
ReportLinksOnly();
return;
}
var required = manifest.Archive.Size + manifest.TotalSize;
@@ -771,6 +793,7 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_dialogs.Show("Espace disque insuffisant",
$"{Format.Bytes(required)} sont nécessaires, mais seulement {Format.Bytes(free)} sont disponibles sur ce lecteur.",
DialogKind.Error);
ReportLinksOnly();
return;
}
}
@@ -784,7 +807,57 @@ public sealed class GameViewModel : ObservableObject, IDisposable
_needsRepair = false;
});
if (ok) ShowBanner("Réparation terminée", "Tous les fichiers du jeu sont de nouveau intègres.", DialogKind.Success);
if (ok)
ShowBanner("Réparation terminée",
linksRepaired is null
? "Tous les fichiers du jeu sont de nouveau intègres."
: $"Tous les fichiers du jeu sont de nouveau intègres. {linksRepaired}",
DialogKind.Success);
else
ReportLinksOnly();
}
/// <summary>
/// Vérifie que les raccourcis du Bureau de ce jeu et l'entrée « Applications installées » de
/// Windows désignent bien ce launcher (il a pu être déplacé ou renommé depuis leur création).
/// • raccourci vers un exécutable disparu → redirigé sans question ;
/// • raccourci vers un AUTRE exemplaire du launcher, encore présent → redirigé après confirmation.
/// </summary>
/// <returns>Message à afficher si au moins un raccourci a été mis à jour, sinon null.</returns>
private string? RepairLauncherLinks()
{
try
{
_registry.RefreshLauncherPath();
var repaired = 0;
foreach (var shortcut in _shortcuts.FindDesktopShortcuts())
{
if (shortcut.PointsToThisLauncher) continue;
if (shortcut.TargetExists &&
!_dialogs.Confirm("Raccourci du Bureau",
$"Le raccourci « {shortcut.Name} » ouvre un autre exemplaire du launcher :\n{shortcut.TargetPath}\n\n" +
$"Le rediriger vers ce launcher ?\n{_shortcuts.LauncherPath}",
"Rediriger", "Ne pas modifier", DialogKind.Question))
continue;
if (_shortcuts.RetargetDesktopShortcut(shortcut)) repaired++;
}
return repaired switch
{
0 => null,
1 => "Le raccourci du Bureau a été mis à jour vers l'emplacement actuel du launcher.",
_ => $"{repaired} raccourcis du Bureau ont été mis à jour vers l'emplacement actuel du launcher."
};
}
catch (Exception ex)
{
// Non bloquant : la réparation des fichiers du jeu continue.
Log.Error("Vérification des raccourcis du Bureau", ex);
return null;
}
}
private async Task VerifyIntegrityAsync()
@@ -988,6 +1061,45 @@ public sealed class GameViewModel : ObservableObject, IDisposable
// ------------------------------------------------------------------ Lancement du jeu
/// <summary>
/// Demande de lancement transmise par un raccourci du Bureau alors que le launcher était déjà
/// ouvert : le jeu est lancé s'il est prêt (et pas déjà en cours d'exécution), sinon le bandeau
/// explique pourquoi.
/// </summary>
public void LaunchFromShortcut()
{
_launchWhenReady = false;
switch (State)
{
case LauncherState.Ready:
Play();
break;
case LauncherState.Checking:
_launchWhenReady = true; // lancé dès la fin de la vérification, si le jeu est prêt
break;
case LauncherState.Running:
ShowBanner("Le jeu est déjà lancé", $"{GameName} est déjà en cours d'exécution.", DialogKind.Info);
break;
case LauncherState.Busy:
ShowBanner("Opération en cours", $"{GameName} pourra être lancé à la fin de l'opération en cours.", DialogKind.Info);
break;
case LauncherState.UpdateAvailable:
ShowBanner("Mise à jour disponible",
$"Une nouvelle version de {GameName} est disponible : cliquez sur « Mettre à jour » avant de jouer.",
DialogKind.Info, autoHide: false);
break;
case LauncherState.NotInstalled:
ShowBanner("Jeu non installé", $"{GameName} n'est pas installé : cliquez sur « Installer ».", DialogKind.Info);
break;
case LauncherState.Broken:
case LauncherState.NeedsRepair:
ShowBanner("Réparation nécessaire",
$"{GameName} doit être réparé avant de pouvoir être lancé : cliquez sur « Réparer ».",
DialogKind.Warning, autoHide: false);
break;
}
}
private void Play()
{
var dir = InstallPath;
@@ -999,6 +1111,14 @@ public sealed class GameViewModel : ObservableObject, IDisposable
return;
}
// Jeu déjà ouvert en dehors de ce launcher (lancé directement, ou avant un redémarrage du launcher).
if (IsAlreadyRunning(exePath))
{
Log.Info($"Lancement ignoré : {exePath} est déjà en cours d'exécution.");
ShowBanner("Le jeu est déjà lancé", $"{GameName} est déjà en cours d'exécution.", DialogKind.Info);
return;
}
try
{
var arguments = string.Join(" ", new[] { _manifest?.LaunchArguments, _config.GameArguments }
@@ -1038,6 +1158,56 @@ public sealed class GameViewModel : ObservableObject, IDisposable
}
}
/// <summary>
/// Vrai si l'exécutable du jeu tourne déjà dans la session Windows courante. Si le chemin d'un
/// processus de même nom ne peut pas être lu (ex. jeu lancé en administrateur), il est considéré
/// comme étant le jeu : mieux vaut ne pas le lancer deux fois.
/// </summary>
private static bool IsAlreadyRunning(string exePath)
{
try
{
var fullPath = Path.GetFullPath(exePath);
int session;
using (var current = Process.GetCurrentProcess()) session = current.SessionId;
var processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(fullPath));
try
{
foreach (var process in processes)
{
try
{
if (process.SessionId != session || process.HasExited) continue;
var path = process.MainModule?.FileName;
if (path is null || string.Equals(Path.GetFullPath(path), fullPath, StringComparison.OrdinalIgnoreCase))
return true;
}
catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or InvalidOperationException
or NotSupportedException)
{
if (!SafeHasExited(process)) return true; // accès refusé : même nom, même session
}
}
}
finally
{
foreach (var process in processes) process.Dispose();
}
}
catch (Exception ex)
{
Log.Warn($"Recherche du processus du jeu impossible : {ex.Message}");
}
return false;
}
private static bool SafeHasExited(Process process)
{
try { return process.HasExited; }
catch (Exception) { return false; }
}
private void OnGameExited(object? sender, EventArgs e)
{
_ui.Post(_ =>
@@ -115,8 +115,10 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
/// <summary>
/// Initialise tous les jeux (détection + mises à jour).
/// <paramref name="uninstallGameId"/> : lancé depuis « Applications installées » de Windows.
/// <paramref name="launchGameId"/> : lancé depuis un raccourci du Bureau ; ce jeu est lancé dès que
/// sa vérification (et son éventuelle mise à jour automatique) est terminée, s'il est prêt.
/// </summary>
public async Task InitializeAsync(bool uninstall, string? uninstallGameId)
public async Task InitializeAsync(bool uninstall, string? uninstallGameId, string? launchGameId = null)
{
var pending = Games.ToList();
@@ -140,14 +142,27 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
}
}
await Task.WhenAll(pending.Select(g => SafeInitializeAsync(g, uninstall: false)));
var launchTarget = uninstall ? null : Find(launchGameId);
if (!uninstall && launchGameId is not null && launchTarget is null)
Log.Warn($"Raccourci vers un jeu inconnu de ce launcher : {launchGameId}");
await Task.WhenAll(pending.Select(g => SafeInitializeAsync(g, uninstall: false,
launchWhenDone: ReferenceEquals(g, launchTarget))));
}
private static async Task SafeInitializeAsync(GameViewModel game, bool uninstall)
private async Task SafeInitializeAsync(GameViewModel game, bool uninstall, bool launchWhenDone = false)
{
try
{
await game.InitializeAsync(uninstall);
// Raccourci du Bureau : le jeu est lancé une fois à jour (réglages « Réduire » / « Fermer »
// le launcher respectés par le lancement lui-même).
if (launchWhenDone && !IsExitConfirmed)
{
Log.Info($"[{game.GameId}] Lancement demandé par le raccourci du Bureau.");
game.LaunchFromShortcut();
}
}
catch (Exception ex)
{
@@ -155,6 +170,24 @@ public sealed class LauncherViewModel : ObservableObject, IDisposable
}
}
/// <summary>
/// Raccourci du Bureau (--game) ouvert alors que le launcher l'était déjà : la fenêtre revient
/// au premier plan sur ce jeu, qui est lancé s'il est prêt.
/// </summary>
public void LaunchFromShortcut(string gameId)
{
_window.Restore();
var game = Find(gameId);
if (game is null)
{
SelectedGame?.ShowNotice("Jeu inconnu",
$"Le raccourci demande le jeu « {gameId} », qui n'est pas proposé par ce launcher.", DialogKind.Warning);
return;
}
SelectedGame = game;
game.LaunchFromShortcut();
}
/// <summary>Vrai si un AUTRE jeu que <paramref name="except"/> a une opération en cours.</summary>
public bool IsAnyOtherGameBusy(GameViewModel? except) =>
Games.Any(g => !ReferenceEquals(g, except) && g.IsBusy);
+37 -41
View File
@@ -1,54 +1,50 @@
# Launcher 1.1.3
# Launcher 1.2.0
LAUNCHER - NOTES DE MISE À JOUR
Mise à jour du 18 octobre 2026
Mise à jour du 24 octobre 2026
==================================================================
Cette version réorganise les paramètres, enrichit les notes de version et corrige le nom du launcher après une mise à jour automatique.
Cette version permet de lancer le jeu directement depuis son icône du Bureau, même quand le launcher est déjà ouvert, et répare les raccourcis après un déplacement du launcher.
------------------------------------------------------------------
PARAMÈTRES
LANCEMENT DEPUIS LE BUREAU
------------------------------------------------------------------
Les paramètres sont maintenant répartis dans deux fenêtres distinctes :
- NOUVEAU : Paramètres du launcher, depuis la roue crantée en haut à
droite. Ces réglages sont communs à tous les jeux :
* mise à jour automatique du launcher et bouton « Vérifier les
mises à jour » ;
* réduire ou fermer le launcher pendant la partie ;
* accès au dossier des journaux.
- NOUVEAU : Paramètres du jeu, depuis le menu « … ». Ces réglages ne
concernent que le jeu sélectionné :
* mises à jour automatiques du jeu ;
* téléchargements simultanés et vérification des fichiers après
l'installation ;
* informations : version installée, dernière version, taille et
dossier du jeu.
- Vos réglages « Lancement du jeu » sont repris automatiquement au
premier démarrage de cette version.
- NOUVEAU : l'icône du jeu sur le Bureau lance directement le jeu.
Le launcher s'ouvre, vérifie les mises à jour, puis démarre la
partie :
* vos réglages « Réduire le launcher pendant la partie » et
« Fermer le launcher au lancement du jeu » sont respectés ;
* si la mise à jour automatique du jeu est activée, la nouvelle
version est installée avant de jouer ;
* sans connexion Internet, la version installée est lancée.
Si le jeu ne peut pas être lancé (mise à jour à installer,
réparation nécessaire...), le launcher reste ouvert et vous
explique pourquoi.
- NOUVEAU : launcher déjà ouvert ? L'icône du Bureau n'affiche plus
le message « Le launcher est déjà ouvert » : le launcher revient
au premier plan sur le bon jeu et le lance.
- Ouvert sans passer par l'icône du jeu, le launcher signale
toujours qu'il est déjà ouvert.
- Le jeu n'est plus lancé une seconde fois s'il est déjà en cours
d'exécution, même s'il a été démarré sans le launcher.
------------------------------------------------------------------
NOTES DE VERSION
LAUNCHER DÉPLACÉ ET RÉPARATION
------------------------------------------------------------------
- NOUVEAU : notes de version plus lisibles, avec titre, sous-titre,
introduction, sections, commentaires, sous-points et étiquettes.
- NOUVEAU : bouton « Agrandir » pour lire les notes de version en
grand.
- NOUVEAU : les nouveautés du launcher utilisent la même présentation.
La fenêtre de mise à jour affiche les notes de toutes les versions
publiées depuis la vôtre.
- Les notes reçues du serveur sont limitées en taille et nettoyées
avant d'être affichées.
Vous avez déplacé ou renommé GameLauncher.exe ? Les raccourcis
suivent :
- Au démarrage, les raccourcis du Bureau qui pointaient vers
l'ancien emplacement sont corrigés automatiquement, même si vous
les avez renommés.
- NOUVEAU : le bouton « Réparer » vérifie aussi les raccourcis du
Bureau et le bouton « Désinstaller » de Windows, et les fait
pointer vers l'emplacement actuel du launcher. Cette vérification
fonctionne même sans connexion Internet.
* si un raccourci ouvre une autre copie du launcher, encore
présente, le launcher vous demande avant de le modifier ;
* l'icône que vous avez personnalisée est conservée.
------------------------------------------------------------------
MISE À JOUR DU LAUNCHER
------------------------------------------------------------------
- Après une mise à jour automatique, le launcher s'appelle toujours
GameLauncher.exe, même si le fichier avait été renommé (par exemple
GameLauncher-1.1.2.exe, tel que publié sur le serveur).
- Le raccourci du Bureau est réparé s'il pointait vers un launcher
renommé ou déplacé.
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation.
La sécurité ne change pas : la signature numérique et l'empreinte SHA-256 de chaque nouvelle version sont vérifiées avant toute installation. La communication entre l'icône du Bureau et le launcher déjà ouvert reste limitée à votre session Windows.
+57 -134
View File
@@ -1,155 +1,78 @@
# Version 1.2
# Version 1.5
EXOVIVAL ZOMBIE REBORN - NOTES DE MISE A JOUR
Niveau 1 « L'école » - Mise à jour du 17 octobre 2026
Niveau 1 « L'école » - Mise à jour du 24 octobre 2026
==================================================================
Merci aux bêta-testeurs ! Cette mise à jour corrige les problèmes remontés pendant la bêta du niveau 1 et ajoute plusieurs améliorations.
Cette mise à jour branche une grande partie de l'école sur le groupe électrogène et son armoire de distribution, rend les objets bien plus faciles à ramasser et corrige plusieurs problèmes de lumière, de traduction et de fin de partie.
------------------------------------------------------------------
PROGRESSION : PROBLÈMES BLOQUANTS CORRIGÉS
COURANT ET GROUPE ELECTROGENE
------------------------------------------------------------------
- La petite clé obtenue en résolvant le labyrinthe de Léa peut
maintenant être ramassée.
- Le cadenas du casier de Léa s'ouvre bien avec sa clé.
- Le coffre du gymnase réagit enfin : il se crochète avec les épingles
à cheveux.
- Le fusible n°2, introuvable jusqu'ici, se trouve maintenant dans ce
coffre.
- Le quatrième interrupteur de l'armoire de distribution n'est plus
caché dans une colonne.
- Le mot de Léa, dans la bibliothèque, n'est plus coincé dans une
chaise : il est posé à plat sur la table, bien visible.
- NOUVEAU : chaque circuit de l'armoire de distribution compte.
* levier ÉCOLE baissé : toute l'école perd le courant (néons,
télé, magnétoscope, vidéosurveillance, gymnase...), l'extérieur
reste éclairé ;
* levier EXTÉRIEUR baissé : seul le projecteur de chantier
s'éteint, l'école reste éclairée.
Relevez le levier, groupe en marche, et le courant revient.
- Les leviers de l'armoire ne se bloquent plus après le premier
démarrage du groupe : on peut encore provoquer une surcharge. Le
voyant de l'armoire s'éteint dès que le réglage n'est plus bon.
- Le lecteur de carte de la porte du toit ne fonctionne plus sans
courant. Une porte déjà ouverte avec la carte reste ouverte après
une coupure.
- L'étiquette du groupe électrogène a changé de place : elle est
maintenant rangée dans le local technique.
- Les messages de coupure et de retour du courant ne donnent plus la
solution : à vous de trouver d'où vient la panne.
------------------------------------------------------------------
ÉNIGMES
LUMIERE
------------------------------------------------------------------
- Labyrinthe à bille : il est maintenant posé sur le bureau (et non
plus sur la chaise), et ses commandes ont été corrigées. Elles ne
s'inversent plus selon l'inclinaison et la bille ne « flotte » plus.
- Labyrinthe : un message d'aide explique les commandes la première
fois, ou indique qu'il manque la bille.
- Planches clouées : une aide indique, la première fois, qu'il faut
bouger la souris pour les arracher. Une fois arrachées, les planches
tombent au sol au lieu de rester en l'air.
- NOUVEAU : néons au plafond dans la remise du concierge, le local
technique et la grande salle du couloir ouest. Ils s'allument avec
le courant et s'éteignent avec lui, comme les autres.
- Les lampes cassées ou tombées au sol (gymnase, couloir vers le
gymnase, bibliothèque) restent éteintes quand le courant revient.
- Lanterne : la forme de sa cage n'est plus projetée tout autour de
vous ; elle produit maintenant de vraies ombres.
- Lanterne : un plein dure 3 minutes au lieu de 60 secondes.
* elle ne consomme que lorsqu'elle est en main ;
* elle se recharge avec du pétrole (Tab, pétrole, « Combiner »
avec la lanterne), pas avec les jerricans.
------------------------------------------------------------------
OBJETS MIEUX PLACÉS ET À LA BONNE TAILLE
OBJETS ET FOUILLE
------------------------------------------------------------------
Plusieurs objets étaient debout, flottaient, traversaient le décor ou
étaient beaucoup trop gros. Ils sont maintenant posés correctement :
- Mot de la directrice et registre : à plat sur les bureaux.
- Clé du local technique : à plat sur un bureau.
- Cassettes vidéo (Kermesse, Noël, EPS, CM1) : à la bonne taille et
posées à plat.
- Une cassette vide nommée « {name} », visible à côté du magnétoscope,
n'apparaît plus.
- Trousse de secours de la salle des profs : à la bonne taille, elle ne
chevauche plus le registre.
- Coffre-fort à molette : il ne flotte plus en l'air.
- Livres qui volaient dans la salle des profs : retirés.
- Carte d'accès rouge et pistolet : posés à plat.
- Épingles à cheveux : au fond du lavabo, sans le traverser.
- Bille de Léa : posée au sol à côté des toilettes au lieu de flotter.
- Radio et détecteur EMF : remis à l'endroit sur le banc.
- Thermomètre : couché sur le côté.
- Caisse à outils du concierge : elle n'est plus à moitié dans le mur.
- Étiquette de distribution : collée à plat sur le groupe électrogène.
- Échelle de la cour : elle s'arrête au niveau des tuiles du toit.
- Projecteur de la cour : remis debout et orienté vers le groupe
électrogène.
- Sac à dos de la bibliothèque : plus petit et posé correctement.
- Trousses de soins, sacs à dos et jerricans trouvés dans les caisses :
à une taille réaliste.
- Il n'est plus nécessaire d'être tout près ou accroupi pour prendre
un objet posé au sol ou sur un meuble bas : la portée passe de
1,5 à 2,5 mètres. Les petits objets (clés, carte, cassettes,
munitions, fusibles, bille) sont aussi plus faciles à viser.
- Caisses en bois : l'objet qui en sort n'est plus caché par les
débris, il se voit et se ramasse tout de suite. La première fois,
un message vous prévient qu'un objet est tombé.
- Un bidon de pétrole remplace une pile dans un casier du couloir
ouest : le niveau compte maintenant 3 bidons pour la lanterne.
- La vraie cassette « CM1 - Mme Roussel (12/10) » a changé de place :
elle est maintenant sur une étagère de la bibliothèque.
- La pile cachée dans les toilettes est n'est plus enfoncée dans la
cuvette.
------------------------------------------------------------------
GROUPE ÉLECTROGÈNE ET COURANT
CORRECTIONS
------------------------------------------------------------------
- Après avoir rempli le groupe électrogène, il n'affiche plus à tort
qu'il est vide.
- Sans carburant, l'interrupteur de démarrage n'affiche plus
« Allumer / Éteindre » comme si le groupe tournait.
- Le courant ne revient que si le groupe électrogène tourne ET que
les bons leviers sont en place.
- NOUVEAU : surcharge. Si un mauvais levier est levé pendant que le
groupe tourne, celui-ci s'arrête. Il faut corriger les leviers puis
le redémarrer à la main.
- NOUVEAU : consommation de carburant. Un jerrican dure environ
20 minutes. À la panne sèche, les lumières s'éteignent :
* les appareils que vous avez déjà utilisés (télé, magnétoscope,
vidéosurveillance) restent utilisables ;
* les autres restent bloqués jusqu'au retour du courant.
Trouvez un autre jerrican pour relancer le groupe.
- NOUVEAU : deux jerricans supplémentaires à trouver dans l'école et
ses alentours.
- La fiche du concierge, dans la remise, affiche bien son titre et
son texte. Avant, elle prenait le nom du dernier objet regardé et
restait vide à la lecture.
- Lampe torche en main : l'aide « UV Flashlight » est traduite
(« Lampe UV »).
- Fin du niveau : au retour au menu principal, la souris réapparaît
et les menus fonctionnent de nouveau. Avant, il fallait fermer le
jeu de force.
------------------------------------------------------------------
CASSETTES VIDÉO, CAMÉSCOPE ET VIDÉOSURVEILLANCE
------------------------------------------------------------------
- Les cassettes ne se dupliquent plus quand on les récupère dans le
magnétoscope, et elles n'écrasent plus celles de l'inventaire.
- Le son des cassettes ne s'entend plus à l'autre bout de l'école.
- La date et l'heure en bas de l'image des cassettes ne sont plus
coupées par le bord de la télé.
- Le sac du caméscope de Mme Roussel est maintenant visible dans le
gymnase.
- Batterie du caméscope : une aide explique qu'il faut avoir le
caméscope en main pour la combiner.
- NOUVEAU : les commandes du caméscope s'affichent à l'écran
(zoom : clic droit ; vision nocturne : touche F).
- NOUVEAU : les commandes de la vidéosurveillance s'affichent
(changer de caméra, orienter la caméra, quitter).
- NOUVEAU : trois caméras de surveillance en plus : gymnase, couloir
principal et entrée de l'école.
------------------------------------------------------------------
TEXTES ET OBJECTIFS
------------------------------------------------------------------
- L'objectif « Trouver le code de la salle des professeurs » se valide
dès la lecture du mot qui donne le code.
- Les deux coffres de la salle des profs affichent leur vrai nom au
lieu de « Door ».
- Le tableau électrique et le boîtier électrique de la bibliothèque
affichent leur vrai nom au lieu de « Trappe ».
------------------------------------------------------------------
DÉCOR, ÉCLAIRAGE ET LIMITES DU NIVEAU
------------------------------------------------------------------
- NOUVEAU : une clôture entoure maintenant l'école et la rue
d'arrivée. On ne peut plus quitter la zone de jeu et se perdre dans
la campagne.
- Toit de la cour de service : les murs invisibles sont remplacés par
une petite grille à piques. Depuis le haut de l'échelle, on ne peut
plus partir sur les tuiles et tomber hors de la cour.
- La silhouette du couloir ouest est enfin visible : elle se détache
dans le noir au bout du couloir. Avant, elle était cachée dans un mur
et pouvait disparaître sans qu'on la voie, à travers une porte fermée.
- Une sphère bleue qui flottait au bout de ce couloir a été retirée.
- La porte des toilettes ouest ne fait plus un petit mouvement de
fermeture avant de s'ouvrir, et le portillon du grillage non plus.
- Éclairage du gymnase : les lampes suspendues éclairent maintenant
le sol, et plus seulement le plafond. Les lampes cassées restent
éteintes.
------------------------------------------------------------------
STABILITÉ
------------------------------------------------------------------
- Correction d'une erreur au chargement d'une sauvegarde.
- Tyrolienne : on ne subit plus de dégâts de chute à l'arrivée dans la
rue, et on ne peut plus mourir pendant la scène de fin. Sauter de la
tyrolienne en cours de route fait toujours des dégâts.
------------------------------------------------------------------
CONNU, EN COURS
------------------------------------------------------------------
- La visée du pistolet fait un simple zoom au lieu de viser par le
viseur. Une amélioration est prévue.
Vos sauvegardes de la version 1.4.3 restent compatibles.
+2 -2
View File
@@ -20,6 +20,6 @@ dotnet run --project "%~dp0ManifestBuilder\ManifestBuilder.csproj" -c Release --
--exe "Exovival Zombie Reborn.exe" ^
--game-id "EZR" ^
--notes "%~dp0notes.txt" ^
--exclude "*.pdb;*.log"
--exclude "*.pdb;*.log" ^
--prune-objects
pause