diff --git a/docs/plans/2026-03-03-single-instance-enforcement-design.md b/docs/plans/2026-03-03-single-instance-enforcement-design.md new file mode 100644 index 0000000..f0d819a --- /dev/null +++ b/docs/plans/2026-03-03-single-instance-enforcement-design.md @@ -0,0 +1,70 @@ +# Single-Instance Enforcement Design + +## Summary + +Prevent multiple instances of the Sendspin Windows client from running simultaneously. When a second instance is launched, it signals the first instance to show its window, then exits silently. + +## Motivation + +Currently, launching the app when it's already running in the system tray creates a completely separate process with its own audio pipeline, server discovery, tray icon, and clock synchronizer. This confuses users and wastes system resources. + +## Design + +### Approach: Named Mutex + Named Pipe + +**Named Mutex** (`Sendspin_SingleInstance`) detects whether an instance is already running. **Named Pipe** (`Sendspin_ShowWindow`) provides cross-process signaling to bring the existing window to the foreground. + +### New File: `src/SendspinClient/SingleInstanceGuard.cs` + +An `IDisposable` class with two roles: + +**First instance (owns the mutex):** +1. Creates the named Mutex — `createdNew` is `true` +2. Starts a `NamedPipeServerStream` listener on a background task +3. When a connection arrives, raises `ShowWindowRequested` event +4. Loops: after each connection, creates a new pipe server to listen again + +**Second instance (mutex already exists):** +1. Tries to create the mutex — `createdNew` is `false` +2. Connects to the named pipe, sends a short message +3. `TryStart()` returns `false` — caller knows to shut down + +### Modified File: `src/SendspinClient/App.xaml.cs` + +At the top of `OnStartup`, before DI/logging/window creation: + +```csharp +_singleInstanceGuard = new SingleInstanceGuard(); +if (!_singleInstanceGuard.TryStart()) +{ + Shutdown(); + return; +} +_singleInstanceGuard.ShowWindowRequested += (_, _) => + Dispatcher.Invoke(() => { /* Show + Activate MainWindow */ }); +``` + +Guard is disposed in `OnExit` (releases mutex, stops pipe server). + +### Window Activation + +When `ShowWindowRequested` fires, dispatch to UI thread: +- `MainWindow.Show()` — unhides from tray +- `MainWindow.WindowState = WindowState.Normal` — restores if minimized +- `MainWindow.Activate()` — brings to foreground + +### Edge Cases + +| Scenario | Behavior | +|----------|----------| +| First instance crashes | OS releases kernel mutex; next launch becomes the first instance | +| First instance exits between mutex check and pipe connect | Pipe connect fails; second instance proceeds as first | +| Multiple rapid launches | Pipe server loops, handles each connection sequentially | +| Pipe connect timeout | 2-second timeout; if exceeded, second instance exits without showing (first instance is likely shutting down) | + +### No Breaking Changes + +- No new dependencies +- No config changes +- No UI changes +- No public API changes diff --git a/src/SendspinClient/App.xaml.cs b/src/SendspinClient/App.xaml.cs index 8e51788..a709eef 100644 --- a/src/SendspinClient/App.xaml.cs +++ b/src/SendspinClient/App.xaml.cs @@ -27,6 +27,7 @@ namespace SendspinClient; /// public partial class App : Application { + private SingleInstanceGuard? _singleInstanceGuard; private ServiceProvider? _serviceProvider; private IConfiguration? _configuration; private MainViewModel? _mainViewModel; @@ -47,6 +48,26 @@ protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + // Single-instance enforcement: if another instance is already running, + // signal it to show its window and shut down immediately + _singleInstanceGuard = new SingleInstanceGuard(); + if (!_singleInstanceGuard.TryStart()) + { + Shutdown(); + return; + } + + _singleInstanceGuard.ShowWindowRequested += (_, _) => + Dispatcher.Invoke(() => + { + if (MainWindow is { } window) + { + window.Show(); + window.WindowState = WindowState.Normal; + window.Activate(); + } + }); + // Initialize user settings directory (copy defaults on first run) AppPaths.InitializeUserSettingsIfNeeded(); @@ -560,6 +581,9 @@ protected override async void OnExit(ExitEventArgs e) { try { + // Dispose single-instance guard (releases mutex, stops pipe server) + _singleInstanceGuard?.Dispose(); + // Dispose tray icon to remove from system tray _trayIcon?.Dispose(); diff --git a/src/SendspinClient/SingleInstanceGuard.cs b/src/SendspinClient/SingleInstanceGuard.cs new file mode 100644 index 0000000..43083b6 --- /dev/null +++ b/src/SendspinClient/SingleInstanceGuard.cs @@ -0,0 +1,113 @@ +using System.IO.Pipes; + +namespace SendspinClient; + +/// +/// Ensures only one instance of the application runs at a time. +/// Uses a named mutex for detection and a named pipe to signal the +/// existing instance to show its window. +/// +internal sealed class SingleInstanceGuard : IDisposable +{ + private const string MutexName = "Sendspin_SingleInstance"; + private const string PipeName = "Sendspin_ShowWindow"; + + private Mutex? _mutex; + private CancellationTokenSource? _pipeCts; + + /// + /// Raised when another instance requests this instance to show its window. + /// Always raised on a background thread — callers must dispatch to the UI thread. + /// + public event EventHandler? ShowWindowRequested; + + /// + /// Attempts to become the single running instance. + /// + /// + /// true if this is the first instance (caller should continue startup). + /// false if another instance is already running (caller should shut down). + /// + public bool TryStart() + { + _mutex = new Mutex(initiallyOwned: true, MutexName, out var createdNew); + + if (createdNew) + { + // We are the first instance — start listening for show-window requests + _pipeCts = new CancellationTokenSource(); + _ = ListenForShowRequestsAsync(_pipeCts.Token); + return true; + } + + // Another instance owns the mutex — signal it to show its window + SignalExistingInstance(); + return false; + } + + /// + /// Listens for incoming pipe connections from subsequent instances. + /// Each connection triggers . + /// + private async Task ListenForShowRequestsAsync(CancellationToken ct) + { + while (!ct.IsCancellationRequested) + { + try + { + await using var server = new NamedPipeServerStream( + PipeName, + PipeDirection.In, + NamedPipeServerStream.MaxAllowedServerInstances, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous); + + await server.WaitForConnectionAsync(ct); + + // Connection received — raise event (don't need to read data) + ShowWindowRequested?.Invoke(this, EventArgs.Empty); + } + catch (OperationCanceledException) + { + break; + } + catch + { + // Pipe error — wait briefly and retry + try { await Task.Delay(500, ct); } + catch (OperationCanceledException) { break; } + } + } + } + + /// + /// Connects to the existing instance's named pipe to signal it + /// to bring its window to the foreground. + /// + private static void SignalExistingInstance() + { + try + { + using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out); + client.Connect(timeout: 2000); + // Connection itself is the signal — no data needed + } + catch + { + // If pipe connect fails, the existing instance may be shutting down. + // Either way, we exit — worst case the user clicks the tray icon. + } + } + + public void Dispose() + { + _pipeCts?.Cancel(); + _pipeCts?.Dispose(); + + if (_mutex != null) + { + try { _mutex.ReleaseMutex(); } catch { } + _mutex.Dispose(); + } + } +}