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();
+ }
+ }
+}