diff --git a/YKanBan.Tests/Ipc/IpcProtocolTests.cs b/YKanBan.Tests/Ipc/IpcProtocolTests.cs
new file mode 100644
index 0000000..9571522
--- /dev/null
+++ b/YKanBan.Tests/Ipc/IpcProtocolTests.cs
@@ -0,0 +1,87 @@
+using YKanBan.Ipc;
+
+namespace YKanBan.Tests.Ipc;
+
+/// Tests for the IPC wire protocol codec.
+[TestClass]
+public class IpcProtocolTests
+{
+ [TestMethod]
+ public void ShowMainEncodesAsSingleLine()
+ {
+ Assert.AreEqual("show\n", IpcProtocol.Encode(new LaunchCommand.ShowMain()));
+ }
+
+ [TestMethod]
+ public void OpenWorkspaceEncodesCommandAndPathLines()
+ {
+ Assert.AreEqual(
+ "open\nD:\\my repo\n",
+ IpcProtocol.Encode(new LaunchCommand.OpenWorkspace(@"D:\my repo")));
+ }
+
+ [TestMethod]
+ public void RoundTripPreservesCommands()
+ {
+ LaunchCommand[] commands =
+ [
+ new LaunchCommand.ShowMain(),
+ new LaunchCommand.OpenWorkspace(@"D:\my repo"),
+ new LaunchCommand.OpenWorkspace("/home/user/仓库"),
+ ];
+
+ foreach (LaunchCommand command in commands)
+ {
+ Assert.AreEqual(command, IpcProtocol.Decode(IpcProtocol.Encode(command)));
+ }
+ }
+
+ [TestMethod]
+ public void DecodeToleratesMissingTrailingNewline()
+ {
+ Assert.AreEqual(new LaunchCommand.ShowMain(), IpcProtocol.Decode("show"));
+ Assert.AreEqual(
+ new LaunchCommand.OpenWorkspace(@"C:\x"),
+ IpcProtocol.Decode("open\nC:\\x"));
+ }
+
+ [TestMethod]
+ public void DecodePreservesPathsWithSpacesAndUnicode()
+ {
+ LaunchCommand command = IpcProtocol.Decode("open\nD:\\我的 仓库\n");
+ Assert.AreEqual(new LaunchCommand.OpenWorkspace(@"D:\我的 仓库"), command);
+ }
+
+ [TestMethod]
+ public void DecodeRejectsEmptyPayload()
+ {
+ Assert.ThrowsExactly(() => IpcProtocol.Decode(string.Empty));
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("\n"));
+ }
+
+ [TestMethod]
+ public void DecodeRejectsUnknownCommand()
+ {
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("frobnicate\n"));
+ }
+
+ [TestMethod]
+ public void DecodeRejectsOpenWithoutPath()
+ {
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("open\n"));
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("open"));
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("open\n\n"));
+ }
+
+ [TestMethod]
+ public void DecodeRejectsContentAfterShow()
+ {
+ Assert.ThrowsExactly(() => IpcProtocol.Decode("show\nextra\n"));
+ }
+
+ [TestMethod]
+ public void EncodeRejectsNullCommand()
+ {
+ Assert.ThrowsExactly(() => IpcProtocol.Encode(null!));
+ }
+}
diff --git a/YKanBan.Tests/Ipc/LaunchArgumentsTests.cs b/YKanBan.Tests/Ipc/LaunchArgumentsTests.cs
new file mode 100644
index 0000000..04308fb
--- /dev/null
+++ b/YKanBan.Tests/Ipc/LaunchArgumentsTests.cs
@@ -0,0 +1,47 @@
+using YKanBan.Ipc;
+
+namespace YKanBan.Tests.Ipc;
+
+/// Tests for command-line argument parsing.
+[TestClass]
+public class LaunchArgumentsTests
+{
+ [TestMethod]
+ public void NoArgumentsYieldShowMain()
+ {
+ Assert.AreEqual(new LaunchCommand.ShowMain(), LaunchArguments.Parse([]));
+ }
+
+ [TestMethod]
+ public void BlankArgumentsOnlyYieldShowMain()
+ {
+ Assert.AreEqual(new LaunchCommand.ShowMain(), LaunchArguments.Parse(["", " "]));
+ }
+
+ [TestMethod]
+ public void SingleArgumentBecomesWorkspacePath()
+ {
+ Assert.AreEqual(
+ new LaunchCommand.OpenWorkspace(@"D:\repo"),
+ LaunchArguments.Parse([@"D:\repo"]));
+ }
+
+ [TestMethod]
+ public void PathWithSpacesIsPreserved()
+ {
+ Assert.AreEqual(
+ new LaunchCommand.OpenWorkspace(@"D:\my repo\ws"),
+ LaunchArguments.Parse([@"D:\my repo\ws"]));
+ }
+
+ [TestMethod]
+ public void FirstMeaningfulArgumentWins()
+ {
+ Assert.AreEqual(
+ new LaunchCommand.OpenWorkspace(@"D:\a"),
+ LaunchArguments.Parse([@"D:\a", @"D:\b"]));
+ Assert.AreEqual(
+ new LaunchCommand.OpenWorkspace(@"D:\b"),
+ LaunchArguments.Parse(["", @"D:\b", @"D:\c"]));
+ }
+}
diff --git a/YKanBan/App.axaml.cs b/YKanBan/App.axaml.cs
index 49fba9c..45aefff 100644
--- a/YKanBan/App.axaml.cs
+++ b/YKanBan/App.axaml.cs
@@ -4,6 +4,8 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
+using Avalonia.Threading;
+using YKanBan.Ipc;
using YKanBan.ViewModels;
using YKanBan.Views;
@@ -25,12 +27,33 @@ public partial class App : Application
{
DataContext = new MainWindowViewModel(),
};
+
+ // Primary instance: serve launch commands forwarded by secondary instances.
+ Program.SingleInstance?.StartServer(command => HandleLaunchCommand(desktop, command));
}
base.OnFrameworkInitializationCompleted();
}
- private void DisableAvaloniaDataAnnotationValidation()
+ ///
+ /// Brings the main window to front for a forwarded launch command. Both
+ /// ShowMain and OpenWorkspace activate the window; switching to the
+ /// requested workspace lands together with the workspace UI.
+ ///
+ private static void HandleLaunchCommand(IClassicDesktopStyleApplicationLifetime desktop, LaunchCommand command)
+ {
+ // IPC delivery happens on a background thread; window access must hop to the UI thread.
+ Dispatcher.UIThread.Post(() =>
+ {
+ if (desktop.MainWindow is { } window)
+ {
+ window.Show();
+ window.Activate();
+ }
+ });
+ }
+
+ private static void DisableAvaloniaDataAnnotationValidation()
{
var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType().ToArray();
diff --git a/YKanBan/Ipc/ISingleInstanceService.cs b/YKanBan/Ipc/ISingleInstanceService.cs
new file mode 100644
index 0000000..5134457
--- /dev/null
+++ b/YKanBan/Ipc/ISingleInstanceService.cs
@@ -0,0 +1,25 @@
+namespace YKanBan.Ipc;
+
+///
+/// Single-instance detection and inter-instance messaging, built on a named
+/// mutex (detection) and a named pipe (command forwarding). The primary
+/// instance holds the mutex for its lifetime and serves the pipe; secondary
+/// instances forward their launch command to the primary and exit.
+///
+public interface ISingleInstanceService : IDisposable
+{
+ /// Attempts to register this process as the single YKanBan instance.
+ /// True when this process is the primary instance; false when another one exists.
+ bool TryAcquireSingleInstance();
+
+ /// Starts serving launch commands on a background task (primary instance only).
+ /// Invoked for each decoded command; runs on a background thread.
+ ///
+ /// Not called on a primary instance, or called a second time.
+ ///
+ void StartServer(Action handler);
+
+ /// Delivers a command to the primary instance and waits for its acknowledgement.
+ /// True when the primary instance acknowledged the command.
+ bool TrySend(LaunchCommand command);
+}
diff --git a/YKanBan/Ipc/IpcProtocol.cs b/YKanBan/Ipc/IpcProtocol.cs
new file mode 100644
index 0000000..7700fea
--- /dev/null
+++ b/YKanBan/Ipc/IpcProtocol.cs
@@ -0,0 +1,62 @@
+namespace YKanBan.Ipc;
+
+///
+/// Text encoding of launch commands on the IPC pipe. A message is one or
+/// two newline-terminated lines: "show", or "open" followed by the
+/// workspace path taken verbatim (paths may contain spaces; paths
+/// containing newlines are not supported). Decoding is strict and throws
+/// FormatException on malformed payloads.
+///
+public static class IpcProtocol
+{
+ /// Encodes a command into its wire text.
+ /// The command type is unknown.
+ public static string Encode(LaunchCommand command) => command switch
+ {
+ LaunchCommand.ShowMain => "show\n",
+ LaunchCommand.OpenWorkspace open => $"open\n{open.Path}\n",
+ _ => throw new InvalidOperationException($"Unsupported launch command: {command?.GetType().Name ?? "null"}"),
+ };
+
+ /// Decodes wire text into a command; trailing newlines are tolerated.
+ /// The payload is not a valid message.
+ public static LaunchCommand Decode(string payload)
+ {
+ List lines = payload.Split('\n').ToList();
+
+ // Trailing newlines are a framing detail; drop the empty lines they produce.
+ while (lines.Count > 0 && lines[^1].Length == 0)
+ {
+ lines.RemoveAt(lines.Count - 1);
+ }
+
+ if (lines.Count == 0)
+ {
+ throw new FormatException("Empty IPC payload.");
+ }
+
+ switch (lines[0])
+ {
+ case "show":
+ if (lines.Count != 1)
+ {
+ throw new FormatException($"Unexpected content after 'show': '{lines[1]}'");
+ }
+ return new LaunchCommand.ShowMain();
+
+ case "open":
+ if (lines.Count != 2)
+ {
+ throw new FormatException("'open' requires exactly one path line.");
+ }
+ if (lines[1].Length == 0)
+ {
+ throw new FormatException("'open' requires a non-empty path.");
+ }
+ return new LaunchCommand.OpenWorkspace(lines[1]);
+
+ default:
+ throw new FormatException($"Unknown IPC command: '{lines[0]}'");
+ }
+ }
+}
diff --git a/YKanBan/Ipc/LaunchArguments.cs b/YKanBan/Ipc/LaunchArguments.cs
new file mode 100644
index 0000000..ca519da
--- /dev/null
+++ b/YKanBan/Ipc/LaunchArguments.cs
@@ -0,0 +1,18 @@
+namespace YKanBan.Ipc;
+
+/// Parses command-line arguments into a launch command.
+public static class LaunchArguments
+{
+ ///
+ /// Parses raw command-line arguments: with no meaningful argument the
+ /// command is ShowMain; otherwise the first non-blank argument is taken
+ /// as the workspace path and any further arguments are ignored.
+ ///
+ public static LaunchCommand Parse(IReadOnlyList arguments)
+ {
+ string? path = arguments.FirstOrDefault(argument => !string.IsNullOrWhiteSpace(argument));
+ return path is null
+ ? new LaunchCommand.ShowMain()
+ : new LaunchCommand.OpenWorkspace(path);
+ }
+}
diff --git a/YKanBan/Ipc/LaunchCommand.cs b/YKanBan/Ipc/LaunchCommand.cs
new file mode 100644
index 0000000..e9967b7
--- /dev/null
+++ b/YKanBan/Ipc/LaunchCommand.cs
@@ -0,0 +1,15 @@
+namespace YKanBan.Ipc;
+
+///
+/// A launch command: what a YKanBan invocation asks the application to do.
+/// Doubles as the IPC message forwarded from a secondary instance to the
+/// primary one.
+///
+public abstract record LaunchCommand
+{
+ /// No arguments: show the main window and bring it to front.
+ public sealed record ShowMain : LaunchCommand;
+
+ /// A workspace path: activate the window and switch to (or open) the workspace.
+ public sealed record OpenWorkspace(string Path) : LaunchCommand;
+}
diff --git a/YKanBan/Ipc/SingleInstanceService.cs b/YKanBan/Ipc/SingleInstanceService.cs
new file mode 100644
index 0000000..214b91c
--- /dev/null
+++ b/YKanBan/Ipc/SingleInstanceService.cs
@@ -0,0 +1,188 @@
+using System.IO.Pipes;
+using System.Text;
+
+namespace YKanBan.Ipc;
+
+///
+/// Mutex + named pipe implementation of .
+/// The mutex name is per-user-session (Local\ prefix), so separate users on
+/// one machine run independently. A crash releases the mutex with the
+/// process — no stale state survives.
+///
+public sealed class SingleInstanceService : ISingleInstanceService
+{
+ private const string MutexName = @"Local\YKanBan.SingleInstance";
+ private const string PipeName = "YKanBan.Ipc";
+ private const int ConnectTimeoutMilliseconds = 2000;
+ private const int ConnectAttempts = 3;
+ private const int RetryDelayMilliseconds = 300;
+
+ private readonly object _gate = new();
+ private Mutex? _mutex;
+ private CancellationTokenSource? _serverCancellation;
+ private bool _isPrimary;
+
+ /// Attempts to register this process as the single YKanBan instance.
+ /// Acquired a second time.
+ public bool TryAcquireSingleInstance()
+ {
+ lock (_gate)
+ {
+ if (_mutex is not null)
+ {
+ throw new InvalidOperationException("Single instance already acquired.");
+ }
+
+ // Ownership is requested up front; createdNew tells primary from secondary.
+ _mutex = new Mutex(initiallyOwned: true, MutexName, out bool createdNew);
+ _isPrimary = createdNew;
+ return createdNew;
+ }
+ }
+
+ /// Starts serving launch commands on a background task (primary instance only).
+ ///
+ /// Not called on a primary instance, or called a second time.
+ ///
+ public void StartServer(Action handler)
+ {
+ CancellationTokenSource cancellation;
+ lock (_gate)
+ {
+ if (!_isPrimary || _mutex is null)
+ {
+ throw new InvalidOperationException("Only the primary instance can serve IPC.");
+ }
+ if (_serverCancellation is not null)
+ {
+ throw new InvalidOperationException("IPC server already started.");
+ }
+ _serverCancellation = new CancellationTokenSource();
+ cancellation = _serverCancellation;
+ }
+
+ _ = Task.Run(() => ServerLoopAsync(handler, cancellation.Token));
+ }
+
+ /// Delivers a command to the primary instance and waits for its acknowledgement.
+ public bool TrySend(LaunchCommand command)
+ {
+ string payload = IpcProtocol.Encode(command);
+
+ // The primary may still be starting its server; retry briefly before giving up.
+ for (int attempt = 0; attempt < ConnectAttempts; attempt++)
+ {
+ try
+ {
+ using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut);
+ client.Connect(ConnectTimeoutMilliseconds);
+
+ using (var writer = new StreamWriter(client, Encoding.UTF8, bufferSize: 64, leaveOpen: true)
+ {
+ AutoFlush = true,
+ })
+ {
+ writer.Write(payload);
+ }
+
+ using var reader = new StreamReader(client, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 64, leaveOpen: true);
+ string acknowledgement = reader.ReadLine() ?? string.Empty;
+ return acknowledgement == "ok";
+ }
+ catch (TimeoutException)
+ {
+ Thread.Sleep(RetryDelayMilliseconds);
+ }
+ catch (IOException)
+ {
+ Thread.Sleep(RetryDelayMilliseconds);
+ }
+ }
+ return false;
+ }
+
+ /// Releases the mutex and stops the IPC server.
+ public void Dispose()
+ {
+ lock (_gate)
+ {
+ _serverCancellation?.Cancel();
+ _serverCancellation?.Dispose();
+ _serverCancellation = null;
+ _mutex?.Dispose();
+ _mutex = null;
+ _isPrimary = false;
+ }
+ }
+
+ /// Accepts one client at a time: read, decode, dispatch, acknowledge, disconnect.
+ private static async Task ServerLoopAsync(Action handler, CancellationToken cancellation)
+ {
+ while (!cancellation.IsCancellationRequested)
+ {
+ try
+ {
+ using var server = new NamedPipeServerStream(PipeName, PipeDirection.InOut, maxNumberOfServerInstances: 1);
+ await server.WaitForConnectionAsync(cancellation);
+
+ string payload = await ReadMessageAsync(server, cancellation);
+ LaunchCommand command = IpcProtocol.Decode(payload);
+
+ // Dispatch to the app; handler failures must not kill the server.
+ try
+ {
+ handler(command);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Swallow handler exceptions: one bad dispatch must not stop serving.
+ }
+
+ WriteAcknowledgement(server);
+ server.Disconnect();
+ }
+ catch (OperationCanceledException)
+ {
+ break;
+ }
+ catch (IOException)
+ {
+ // Client vanished mid-conversation: drop the connection and keep serving.
+ }
+ catch (FormatException)
+ {
+ // Malformed payload: no acknowledgement is written; keep serving.
+ }
+ }
+ }
+
+ /// Reads the command line plus, for open, the path line.
+ private static async Task ReadMessageAsync(PipeStream pipe, CancellationToken cancellation)
+ {
+ using var reader = new StreamReader(pipe, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
+
+ string firstLine = await reader.ReadLineAsync(cancellation)
+ ?? throw new IOException("Connection closed before the command line arrived.");
+ if (firstLine != "open")
+ {
+ return firstLine;
+ }
+
+ string? pathLine = await reader.ReadLineAsync(cancellation);
+ if (pathLine is null)
+ {
+ throw new IOException("Connection closed before the path line arrived.");
+ }
+ return $"open\n{pathLine}";
+ }
+
+ /// Writes the acknowledgement the sender waits for.
+ private static void WriteAcknowledgement(PipeStream pipe)
+ {
+ using var writer = new StreamWriter(pipe, Encoding.UTF8, bufferSize: 64, leaveOpen: true)
+ {
+ AutoFlush = true,
+ };
+ writer.Write("ok\n");
+ }
+}
diff --git a/YKanBan/Program.cs b/YKanBan/Program.cs
index b63de49..88c9c0c 100644
--- a/YKanBan/Program.cs
+++ b/YKanBan/Program.cs
@@ -1,13 +1,30 @@
using System;
using Avalonia;
+using YKanBan.Ipc;
namespace YKanBan;
sealed class Program
{
+ /// The single-instance service held by this process while the app runs.
+ internal static ISingleInstanceService? SingleInstance { get; private set; }
+
[STAThread]
- public static void Main(string[] args) => BuildAvaloniaApp()
- .StartWithClassicDesktopLifetime(args);
+ public static void Main(string[] args)
+ {
+ LaunchCommand command = LaunchArguments.Parse(args);
+ using ISingleInstanceService singleInstance = new SingleInstanceService();
+
+ // Secondary instance: forward the command to the primary, then exit immediately.
+ if (!singleInstance.TryAcquireSingleInstance())
+ {
+ singleInstance.TrySend(command);
+ return;
+ }
+
+ SingleInstance = singleInstance;
+ BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
+ }
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure()