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