diff --git a/README.en.md b/README.en.md index ab13280..7fb22aa 100644 --- a/README.en.md +++ b/README.en.md @@ -126,6 +126,28 @@ using (await asyncLock.LockAsync()) } ``` +##### 13. Single Instance Manager +```csharp +using LuYao.Threading; + +// Call at application startup to ensure only one instance is running. +// If another instance is already running, sends an activation request to it +// and exits the current process. +SingleInstanceManager.EnsureSingleInstance(); + +// Subscribe to the activation event to bring your window to the foreground +// when another instance tries to start. +SingleInstanceManager.ActivateWindowRequested += (sender, args) => +{ + // Bring the main window to the front + mainWindow.Activate(); + mainWindow.WindowState = WindowState.Normal; +}; + +// Release the lock manually if needed (use with caution) +SingleInstanceManager.ReleaseLock(); +``` + ##### 6. String Compression ```csharp using LuYao.Encoders; diff --git a/README.zh-CN.md b/README.zh-CN.md index dec3234..6a2436f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -126,6 +126,26 @@ using (await asyncLock.LockAsync()) } ``` +##### 13. 单实例管理器 +```csharp +using LuYao.Threading; + +// 在应用程序启动时调用,确保只有一个实例在运行。 +// 若检测到已有实例,则向已有实例发送激活请求后退出当前进程。 +SingleInstanceManager.EnsureSingleInstance(); + +// 订阅激活事件,在收到其他实例的激活请求时触发(例如将窗口提到前台) +SingleInstanceManager.ActivateWindowRequested += (sender, args) => +{ + // 将主窗口激活并置于前台 + mainWindow.Activate(); + mainWindow.WindowState = WindowState.Normal; +}; + +// 如需手动释放锁(请谨慎使用) +SingleInstanceManager.ReleaseLock(); +``` + ##### 6. 字符串压缩 ```csharp using LuYao.Encoders; diff --git a/src/LuYao.Common/Threading/SingleInstanceManager.cs b/src/LuYao.Common/Threading/SingleInstanceManager.cs new file mode 100644 index 0000000..9293a49 --- /dev/null +++ b/src/LuYao.Common/Threading/SingleInstanceManager.cs @@ -0,0 +1,130 @@ +using System; +using System.IO; +using System.IO.Pipes; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace LuYao.Threading; + +/// +/// Provides single-instance process management. Ensures only one instance of the +/// application runs at a time, and allows the new instance to signal the existing +/// one to activate its window via a named pipe. +/// +public static class SingleInstanceManager +{ + private const string Command_ActivateWindow = "ActivateWindow"; + private static Mutex? _processLock; + private static bool _hasLock; + + /// + /// Raised when another instance of the application requests that the current + /// instance activates its main window. + /// + public static event EventHandler? ActivateWindowRequested; + + /// + /// Ensures that only one instance of the application is running. + /// If this is the first instance, it starts listening on a named pipe for + /// activation requests. If another instance is already running, it sends an + /// activation request to the existing instance and terminates the current process. + /// + public static void EnsureSingleInstance() + { + var uid = GetUid(); + var mutexName = $"Global\\LuYao.SingleInstance[{uid}]"; + var pipeName = $"LuYaoSI{uid}"; + _processLock = new Mutex(false, mutexName, out _hasLock); + if (_hasLock) + { + Task.Factory.StartNew(() => ServerThread(pipeName), TaskCreationOptions.LongRunning); + } + else + { + RequestActivateWindow(pipeName); + Environment.Exit(0); + } + } + + /// + /// Releases the single-instance lock held by the current process. + /// + /// Use with caution — releasing the lock allows another instance to become the primary instance. + public static void ReleaseLock() + { + if (_processLock != null && _hasLock) + { + _processLock.Dispose(); + _hasLock = false; + } + } + + private static string GetUid() + { +#if NET6_0_OR_GREATER + var path = Environment.ProcessPath ?? AppContext.BaseDirectory; +#elif NET45 + var path = AppDomain.CurrentDomain.BaseDirectory; +#else + var path = AppContext.BaseDirectory; +#endif + var bytes = Encoding.UTF8.GetBytes(path); + using (var md5 = MD5.Create()) + { + bytes = md5.ComputeHash(bytes); + } + return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); + } + + private static async Task ServerThread(string pipeName) + { + while (true) + { + try + { + using (var server = new NamedPipeServerStream(pipeName)) + { +#if NETFRAMEWORK + await Task.Run(() => server.WaitForConnection()); +#else + await server.WaitForConnectionAsync(); +#endif + using (var reader = new StreamReader(server)) + { + var command = await reader.ReadLineAsync(); + if (command == Command_ActivateWindow) + { + try + { + ActivateWindowRequested?.Invoke(null, EventArgs.Empty); + } + catch + { + // Prevent a subscriber exception from terminating the server loop. + } + } + } + } + } + catch + { + // Prevent a pipe I/O error from terminating the server loop. + } + } + } + + private static void RequestActivateWindow(string pipeName) + { + using (var client = new NamedPipeClientStream(pipeName)) + { + client.Connect(1000); + using (var writer = new StreamWriter(client)) + { + writer.WriteLine(Command_ActivateWindow); + writer.Flush(); + } + } + } +} diff --git a/tests/LuYao.Common.UnitTests/Threading/SingleInstanceManagerTests.cs b/tests/LuYao.Common.UnitTests/Threading/SingleInstanceManagerTests.cs new file mode 100644 index 0000000..167b62e --- /dev/null +++ b/tests/LuYao.Common.UnitTests/Threading/SingleInstanceManagerTests.cs @@ -0,0 +1,67 @@ +using System.IO.Pipes; + +namespace LuYao.Threading; + +[TestClass] +public class SingleInstanceManagerTests +{ + [TestMethod] + public void ReleaseLock_WhenNoLockHeld_ShouldNotThrow() + { + // Arrange & Act: calling ReleaseLock without holding a lock should be safe + SingleInstanceManager.ReleaseLock(); + } + + [TestMethod] + public void ReleaseLock_CalledTwice_ShouldNotThrow() + { + // Act & Assert: calling ReleaseLock multiple times should be safe + SingleInstanceManager.ReleaseLock(); + SingleInstanceManager.ReleaseLock(); + } + + [TestMethod] + public void ActivateWindowRequested_SubscribeAndUnsubscribe_ShouldNotThrow() + { + // Arrange + EventHandler handler = (_, _) => { }; + + // Act & Assert: subscribing and unsubscribing should not throw + SingleInstanceManager.ActivateWindowRequested += handler; + SingleInstanceManager.ActivateWindowRequested -= handler; + } + + [TestMethod] + public async Task NamedPipe_ClientCanConnectAndSendCommand() + { + // Arrange: verify that a named pipe server/client pair works for the ActivateWindow command + const string pipeName = "LuYaoSI_unit_test_pipe_connect"; + const string expectedCommand = "ActivateWindow"; + string? receivedCommand = null; + var tcs = new TaskCompletionSource(); + + var serverTask = Task.Run(async () => + { + using var server = new NamedPipeServerStream(pipeName); + await server.WaitForConnectionAsync(); + using var reader = new StreamReader(server); + tcs.TrySetResult(await reader.ReadLineAsync()); + }); + + // Act: connect as a client and write the command + using (var client = new NamedPipeClientStream(pipeName)) + { + client.Connect(2000); + using var writer = new StreamWriter(client); + writer.WriteLine(expectedCommand); + writer.Flush(); + } + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(3000)); + Assert.AreEqual(tcs.Task, completed, "Server should have received the command within timeout."); + receivedCommand = tcs.Task.Result; + + // Assert + Assert.AreEqual(expectedCommand, receivedCommand); + } +}