From 8988d130ac0d55208c6f14ff27eef588ef89b966 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 2 May 2026 12:31:11 +0200 Subject: [PATCH] Single-instance launcher + tolerant build pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-instance --------------- Two PSLauncher.exe instances ran in parallel during the user's last release build, locking the repo-root copy and breaking the publish. Two-layer protection: 1. App.xaml.cs uses a Global\ named Mutex (UUID-based key) created at OnStartup. If the mutex is already held, the second instance PostMessages a registered Windows message (PSLAUNCHER_BRING_TO_FRONT) to HWND_BROADCAST and Shutdown()s immediately. 2. MainWindow hooks WndProc via SourceInitialized + HwndSource.AddHook; when it sees the broadcast, it restores from minimized, calls ShowWindow(SW_RESTORE) + Activate() + SetForegroundWindow so the already-running instance pops to the user's foreground. Net result: clicking the launcher icon a second time pops the existing window instead of starting a duplicate process. Tolerant build pipeline ----------------------- - Both csproj post-publish copy targets now have ContinueOnError="WarnAndContinue". A locked PSLauncher.exe at the repo root no longer fails the entire publish — the binary still exists in bin\Release\...\publish\ and the user gets a warning instead of an error. - All four .bat scripts (build-launcher / build-updater / build-all / build-installer) now run `taskkill /F /IM PSLauncher.exe /T` and the same for PSLauncher.Updater.exe before the publish step. This defensively closes any stray instance left behind by previous tests so the build pipeline is clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- build-all.bat | 4 ++ build-installer.bat | 4 ++ build-launcher.bat | 4 ++ build-updater.bat | 4 ++ src/PSLauncher.App/App.xaml.cs | 26 +++++++++++ src/PSLauncher.App/PSLauncher.App.csproj | 8 ++-- src/PSLauncher.App/Views/MainWindow.xaml.cs | 44 +++++++++++++++++++ .../PSLauncher.Updater.csproj | 3 +- 8 files changed, 93 insertions(+), 4 deletions(-) diff --git a/build-all.bat b/build-all.bat index 3d7dde1..8f988fc 100644 --- a/build-all.bat +++ b/build-all.bat @@ -5,6 +5,10 @@ REM Pour l'installeur Inno Setup, utiliser build-installer.bat. setlocal set "REPO=%~dp0" echo. +echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe +taskkill /F /IM PSLauncher.exe /T >nul 2>&1 +taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 +echo. echo ==^> dotnet publish PSLauncher.App (Release single-file) echo. dotnet publish "%REPO%src\PSLauncher.App\PSLauncher.App.csproj" -c Release --nologo diff --git a/build-installer.bat b/build-installer.bat index 4f5d46f..84cd3bc 100644 --- a/build-installer.bat +++ b/build-installer.bat @@ -12,6 +12,10 @@ REM powershell -ExecutionPolicy Bypass -File installer\build-installer.ps1 -IS setlocal set "SCRIPT_DIR=%~dp0" +REM Termine les eventuelles instances en cours qui verrouilleraient les .exe +taskkill /F /IM PSLauncher.exe /T >nul 2>&1 +taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 + powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%installer\build-installer.ps1" %* set "EXITCODE=%ERRORLEVEL%" diff --git a/build-launcher.bat b/build-launcher.bat index 32f02ed..fb0c88d 100644 --- a/build-launcher.bat +++ b/build-launcher.bat @@ -5,6 +5,10 @@ REM Sortie : C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe (~78 Mo single-file self setlocal set "REPO=%~dp0" echo. +echo ==^> Termine les eventuelles instances de PSLauncher qui verrouilleraient les .exe +taskkill /F /IM PSLauncher.exe /T >nul 2>&1 +taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 +echo. echo ==^> dotnet publish PSLauncher.App (Release single-file) echo. diff --git a/build-updater.bat b/build-updater.bat index a72b79a..3b78336 100644 --- a/build-updater.bat +++ b/build-updater.bat @@ -6,6 +6,10 @@ REM L'updater accompagne PSLauncher.exe — il doit etre dans le meme dossier. setlocal set "REPO=%~dp0" echo. +echo ==^> Termine les eventuelles instances qui verrouilleraient les .exe +taskkill /F /IM PSLauncher.exe /T >nul 2>&1 +taskkill /F /IM PSLauncher.Updater.exe /T >nul 2>&1 +echo. echo ==^> dotnet publish PSLauncher.Updater (Release single-file) echo. diff --git a/src/PSLauncher.App/App.xaml.cs b/src/PSLauncher.App/App.xaml.cs index 132e913..bffe8f8 100644 --- a/src/PSLauncher.App/App.xaml.cs +++ b/src/PSLauncher.App/App.xaml.cs @@ -1,6 +1,7 @@ using System.IO; using System.Net.Http; using System.Net.Http.Headers; +using System.Runtime.InteropServices; using System.Windows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,6 +25,20 @@ namespace PSLauncher.App; public partial class App : Application { private IHost? _host; + private static System.Threading.Mutex? _singleInstanceMutex; + + private const string SingleInstanceMutexName = "Global\\PSLauncher-3F8E2C1A-9B47-4D5E-A0F8-2E9D1B6C7A3F"; + private const string BringToFrontMessage = "PSLAUNCHER_BRING_TO_FRONT"; + private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage(BringToFrontMessage); + + [DllImport("user32.dll")] + private static extern int RegisterWindowMessage(string lpString); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool PostMessage(IntPtr hWnd, int Msg, IntPtr wParam, IntPtr lParam); + + private const int HWND_BROADCAST = 0xFFFF; public static string LogsDirectory { get; private set; } = string.Empty; @@ -31,6 +46,17 @@ public partial class App : Application { base.OnStartup(e); + // Single-instance : si une autre instance tourne déjà, on lui demande de + // se mettre au premier plan via un broadcast Windows et on quitte tout de suite. + _singleInstanceMutex = new System.Threading.Mutex(initiallyOwned: true, + name: SingleInstanceMutexName, out var createdNew); + if (!createdNew) + { + PostMessage((IntPtr)HWND_BROADCAST, WM_PSLAUNCHER_BRING, IntPtr.Zero, IntPtr.Zero); + Shutdown(); + return; + } + // Logs avant tout — pour ne rien perdre des erreurs au boot LogsDirectory = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj index c263904..7d88862 100644 --- a/src/PSLauncher.App/PSLauncher.App.csproj +++ b/src/PSLauncher.App/PSLauncher.App.csproj @@ -55,8 +55,9 @@ + (C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe). ContinueOnError pour ne pas casser le + publish si une instance du launcher tourne et verrouille la cible — l'opérateur peut + killer et réessayer, ou simplement utiliser le binaire depuis bin\Release\... --> @@ -65,7 +66,8 @@ + OverwriteReadOnlyFiles="true" + ContinueOnError="WarnAndContinue" /> diff --git a/src/PSLauncher.App/Views/MainWindow.xaml.cs b/src/PSLauncher.App/Views/MainWindow.xaml.cs index 8ae2ace..69420b0 100644 --- a/src/PSLauncher.App/Views/MainWindow.xaml.cs +++ b/src/PSLauncher.App/Views/MainWindow.xaml.cs @@ -1,14 +1,58 @@ +using System.Runtime.InteropServices; using System.Windows; using System.Windows.Controls; using System.Windows.Controls.Primitives; +using System.Windows.Interop; namespace PSLauncher.App.Views; public partial class MainWindow : Window { + private static readonly int WM_PSLAUNCHER_BRING = RegisterWindowMessage("PSLAUNCHER_BRING_TO_FRONT"); + + [DllImport("user32.dll")] + private static extern int RegisterWindowMessage(string lpString); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + private const int SW_RESTORE = 9; + public MainWindow() { InitializeComponent(); + SourceInitialized += MainWindow_SourceInitialized; + } + + /// + /// Hook le pump de messages Windows pour réagir au broadcast envoyé par une + /// seconde instance qui veut nous mettre au premier plan. + /// + private void MainWindow_SourceInitialized(object? sender, EventArgs e) + { + var helper = new WindowInteropHelper(this); + var source = HwndSource.FromHwnd(helper.Handle); + source?.AddHook(WndProc); + } + + private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + if (msg == WM_PSLAUNCHER_BRING) + { + if (WindowState == WindowState.Minimized) + WindowState = WindowState.Normal; + else + ShowWindow(hwnd, SW_RESTORE); + Activate(); + SetForegroundWindow(hwnd); + handled = true; + } + return IntPtr.Zero; } /// diff --git a/src/PSLauncher.Updater/PSLauncher.Updater.csproj b/src/PSLauncher.Updater/PSLauncher.Updater.csproj index 8167776..0ecf975 100644 --- a/src/PSLauncher.Updater/PSLauncher.Updater.csproj +++ b/src/PSLauncher.Updater/PSLauncher.Updater.csproj @@ -32,7 +32,8 @@ + OverwriteReadOnlyFiles="true" + ContinueOnError="WarnAndContinue" />