Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions docs/plans/2026-03-03-single-instance-enforcement-design.md
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions src/SendspinClient/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ namespace SendspinClient;
/// </summary>
public partial class App : Application
{
private SingleInstanceGuard? _singleInstanceGuard;
private ServiceProvider? _serviceProvider;
private IConfiguration? _configuration;
private MainViewModel? _mainViewModel;
Expand All @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
113 changes: 113 additions & 0 deletions src/SendspinClient/SingleInstanceGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
using System.IO.Pipes;

namespace SendspinClient;

/// <summary>
/// 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.
/// </summary>
internal sealed class SingleInstanceGuard : IDisposable
{
private const string MutexName = "Sendspin_SingleInstance";
private const string PipeName = "Sendspin_ShowWindow";

private Mutex? _mutex;
private CancellationTokenSource? _pipeCts;

/// <summary>
/// Raised when another instance requests this instance to show its window.
/// Always raised on a background thread — callers must dispatch to the UI thread.
/// </summary>
public event EventHandler? ShowWindowRequested;

/// <summary>
/// Attempts to become the single running instance.
/// </summary>
/// <returns>
/// <c>true</c> if this is the first instance (caller should continue startup).
/// <c>false</c> if another instance is already running (caller should shut down).
/// </returns>
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;
}

/// <summary>
/// Listens for incoming pipe connections from subsequent instances.
/// Each connection triggers <see cref="ShowWindowRequested"/>.
/// </summary>
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; }
}
}
}

/// <summary>
/// Connects to the existing instance's named pipe to signal it
/// to bring its window to the foreground.
/// </summary>
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();
}
}
}
Loading