In this article, we’ll look at how to restrict your WinUI 3 app to a single instance using C#.
There are cases where running an application in a single instance is essential, such as for media players or system utilities. This article builds on that concept, with specific adjustments for unpackaged applications in WinUI 3.
We’ll use the project from the previous article as a starting point.
We need to implement two services: an application uniqueness checker and a Named Pipe service. While the first service largely follows the approach from the documentation, the second is necessary for enabling communication between application instances. Named Pipes are a powerful inter-process communication (IPC) mechanism that allows data exchange between applications, even across different processes.
The workflow is as follows: First, we check if the current instance of the application is the only one running. If it is, we proceed with initialization. If not, we send the launch parameters (the args from the Main method) via Named Pipes and then terminate the application. This is the key difference from packaged applications. Unpackaged applications don’t use the activation mechanism, making it impossible to pass parameters through the OnLaunched
method. Instead, Named Pipes provide a reliable way to handle this scenario.
The SingleInstanceService
class ensures that only one instance of the application runs at a time and handles sending/receiving notifications via Named Pipes. The IsFirstInstance
method checks if the current instance is the first one. If it’s not, it sends the launch arguments to the first instance using Named Pipes and returns false
. If it is the first instance, it subscribes to the Activated
event to handle future activation requests. Here’s the code:
public static bool IsFirstInstance(string[] args)
{
appInstance = AppInstance.FindOrRegisterForKey("com.albertakhmetov.singleinstance");
if (!appInstance.IsCurrent)
{
PipeService.SendData(GetData(args)).Wait();
AppActivationArguments a = AppInstance.GetCurrent().GetActivatedEventArgs();
appInstance.RedirectActivationToAsync(a).AsTask().Wait(TimeSpan.FromSeconds(5));
return false;
}
else
{
appInstance.Activated += OnAppInstanceActivated;
return true;
}
}
To prepare the launch arguments for transmission, the GetData
method converts the args
array into a single string. If no arguments are provided, it returns an empty string. Here’s how it works:
private static string GetData(string[] args)
{
if (args == null || args.Length == 0)
{
return string.Empty;
}
else
{
var sb = new StringBuilder();
foreach (var i in args)
{
if (sb.Length > 0)
{
sb.AppendLine();
}
sb.Append(i);
}
return sb.ToString();
}
}
When another instance attempts to activate the application, the OnAppInstanceActivated
method is triggered. It brings the first instance’s main window to the foreground, ensuring the user interacts with the correct instance:
private static void OnAppInstanceActivated(object? sender, AppActivationArguments e)
{
if (appInstance != null)
{
var process = Process.GetProcessById((int)appInstance!.ProcessId);
PInvoke.SetForegroundWindow(new HWND(process.MainWindowHandle));
}
}
To process data received from Named Pipes, the OnActivated
method parses the string and notifies subscribers via the activatedSubject
. If the data is empty, it sends an empty array. Here’s the code:
public void OnActivated(string? data)
{
if (string.IsNullOrWhiteSpace(data))
{
activatedSubject.OnNext(ImmutableArray<string>.Empty);
}
else
{
using var reader = new StringReader(data);
var builder = ImmutableArray.CreateBuilder<string>();
var line = default(string);
while ((line = reader.ReadLine()) != null)
{
builder.Add(line);
}
activatedSubject.OnNext(builder.ToImmutable());
}
}
The class uses a Subject<ImmutableArray<string>>
to provide a reactive way to notify subscribers when the application is activated with new arguments. The Activated
property exposes this as an IObservable
for external components to subscribe to. Here’s the relevant code:
private Subject<ImmutableArray<string>> activatedSubject;
public SingleInstanceService()
{
activatedSubject = new Subject<ImmutableArray<string>>();
Activated = activatedSubject.AsObservable();
}
public IObservable<ImmutableArray<string>> Activated { get; }
Finally, the appInstance
field stores the AppInstance
object, which is used to manage application instances and handle activation events:
private static AppInstance? appInstance;
The PipeService
class handles communication between application instances using Named Pipes. It implements IHostedService
to run as a background service, listening for incoming data and notifying the ISingleInstanceService
when new data is received.
The SendData
method sends a string to the Named Pipe server. It creates a NamedPipeClientStream
, connects to the server, and writes the data using a StreamWriter
:
public static async Task SendData(string data)
{
using (var pipeClient = new NamedPipeClientStream(".", PIPE_NAME, PipeDirection.Out, PipeOptions.Asynchronous))
{
await pipeClient.ConnectAsync(1000);
using (var writer = new StreamWriter(pipeClient, Encoding.UTF8))
{
await writer.WriteAsync(data);
await writer.FlushAsync();
}
}
}
The class constructor initializes the service with a reference to the ISingleInstanceService
and a CancellationTokenSource
for managing the background task:
private readonly ISingleInstanceService singleInstanceService;
private CancellationTokenSource tokenSource;
public PipeService(ISingleInstanceService singleInstanceService)
{
this.singleInstanceService = singleInstanceService;
tokenSource = new CancellationTokenSource();
}
The StartAsync
method starts the Named Pipe server in a background task. It continuously listens for incoming connections, reads data from the pipe, and forwards it to the ISingleInstanceService
via the OnActivated
method. The loop runs until the cancellation token is triggered. Here’s how it works:
public Task StartAsync(CancellationToken cancellationToken)
{
return Task.Factory.StartNew(async x =>
{
var token = (CancellationToken)x!;
while (true)
{
using (var pipeServer = new NamedPipeServerStream(PIPE_NAME, PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous))
{
await pipeServer.WaitForConnectionAsync(token);
using (var reader = new StreamReader(pipeServer, Encoding.UTF8))
{
var receivedData = await reader.ReadToEndAsync();
singleInstanceService.OnActivated(receivedData);
}
}
if (token.IsCancellationRequested)
{
break;
}
}
}, tokenSource.Token);
}
To stop the service, the StopAsync
method cancels the background task using the CancellationTokenSource
:
public Task StopAsync(CancellationToken cancellationToken)
{
return tokenSource.CancelAsync();
}
You can find an example of how to use this implementation in the provided project: SingleInstance Example on GitHub. After compiling the project, you can test the application’s behavior. To observe how the first instance works, launch the program under the debugger and then try running the executable file from File Explorer. To test launching with arguments, simply drag and drop one or more files onto the executable file. To see what happens when the application is already running, do the opposite: first launch the executable from File Explorer, and then start the program under the debugger. Happy coding!