Single-instance launcher + tolerant build pipeline

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-02 12:31:11 +02:00
parent f5f1fe4d36
commit 8988d130ac
8 changed files with 93 additions and 4 deletions

View File

@@ -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),

View File

@@ -55,8 +55,9 @@
</ItemGroup>
<!-- Après chaque `dotnet publish -c Release`, copie le single-file exe à la racine du repo
(C:\ASTERION\GIT\PS_Launcher\PSLauncher.exe) pour avoir un binaire prêt à tester
sans aller chercher dans bin\Release\... -->
(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\... -->
<Target Name="CopyPublishedToRepoRoot" AfterTargets="Publish"
Condition=" '$(Configuration)' == 'Release' ">
<PropertyGroup>
@@ -65,7 +66,8 @@
<Copy SourceFiles="$(PublishDir)$(AssemblyName).exe"
DestinationFolder="$(RepoRoot)"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true" />
OverwriteReadOnlyFiles="true"
ContinueOnError="WarnAndContinue" />
<Message Importance="high" Text="Copied $(AssemblyName).exe to $(RepoRoot)" />
</Target>

View File

@@ -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;
}
/// <summary>
/// Hook le pump de messages Windows pour réagir au broadcast envoyé par une
/// seconde instance qui veut nous mettre au premier plan.
/// </summary>
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;
}
/// <summary>