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
22 changes: 22 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
130 changes: 130 additions & 0 deletions src/LuYao.Common/Threading/SingleInstanceManager.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class SingleInstanceManager
{
private const string Command_ActivateWindow = "ActivateWindow";
private static Mutex? _processLock;
private static bool _hasLock;

/// <summary>
/// Raised when another instance of the application requests that the current
/// instance activates its main window.
/// </summary>
public static event EventHandler? ActivateWindowRequested;

/// <summary>
/// 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.
/// </summary>
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);
}
}

/// <summary>
/// Releases the single-instance lock held by the current process.
/// </summary>
/// <remarks>Use with caution — releasing the lock allows another instance to become the primary instance.</remarks>
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();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<string?>();

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