From 4fc8fdda6f1e6ca011a45eaf7fb049ea79cf74fb Mon Sep 17 00:00:00 2001 From: yyc12345 Date: Fri, 2 Oct 2026 14:29:42 +0800 Subject: [PATCH] feat(m4): single instance IPC - LaunchCommand: ShowMain / OpenWorkspace(path), doubles as IPC message - LaunchArguments: first non-blank arg wins, blank-only args -> ShowMain - IpcProtocol: strict newline-line codec (show / open+path), tolerant of trailing-newline framing only - SingleInstanceService: per-session named mutex decides primary; primary serves a named-pipe loop (decode -> dispatch -> ack) with handler failures contained; secondary retries send, exits on ack - Program/App wiring: secondary forwards command and exits; primary activates the main window for forwarded commands (workspace switch deferred to the workspace UI milestone) - 15 new tests (protocol codec, argument parsing); smoke-verified: instance 1 stays alive, instance 2 self-exits with code 0 --- YKanBan.Tests/Ipc/IpcProtocolTests.cs | 87 ++++++++++ YKanBan.Tests/Ipc/LaunchArgumentsTests.cs | 47 ++++++ YKanBan/App.axaml.cs | 25 ++- YKanBan/Ipc/ISingleInstanceService.cs | 25 +++ YKanBan/Ipc/IpcProtocol.cs | 62 +++++++ YKanBan/Ipc/LaunchArguments.cs | 18 +++ YKanBan/Ipc/LaunchCommand.cs | 15 ++ YKanBan/Ipc/SingleInstanceService.cs | 188 ++++++++++++++++++++++ YKanBan/Program.cs | 21 ++- 9 files changed, 485 insertions(+), 3 deletions(-) create mode 100644 YKanBan.Tests/Ipc/IpcProtocolTests.cs create mode 100644 YKanBan.Tests/Ipc/LaunchArgumentsTests.cs create mode 100644 YKanBan/Ipc/ISingleInstanceService.cs create mode 100644 YKanBan/Ipc/IpcProtocol.cs create mode 100644 YKanBan/Ipc/LaunchArguments.cs create mode 100644 YKanBan/Ipc/LaunchCommand.cs create mode 100644 YKanBan/Ipc/SingleInstanceService.cs 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()