Browse Source

Reimplement cross-platform single-instance handling

The Avalonia port dropped ILSpy's single-instance feature, leaving three
inert surfaces behind: the --newinstance / --noactivate switches, and the
"Allow multiple instances" option were parsed and persisted but never read,
and every launch started a new process.

The former WPF implementation also encoded the executable location in the
mutex name, so two ILSpy builds at different paths never shared an instance.
That broke the Windows "Open with ILSpy" shell command: it launches a fixed
executable and would not reuse a running instance started from elsewhere.

Reimplement it with portable primitives (named Mutex + named pipes, no
P/Invoke): the first launch for a user takes the mutex and listens; a later
launch forwards its arguments over the pipe and exits. The namespace is
derived from machine + user only -- never the location -- so any launcher
reuses the running instance. --instanceid is a runtime reuse filter matched
against the running instance's actual executable identity (via
Environment.ProcessPath, single-file-bundle safe), not part of the mutex
name, so it never re-introduces the location partitioning it replaces. The
VS add-in passes its bundled exe path as --instanceid to prefer its own
build while still sharing with a plain launch of that same executable.

Assisted-by: Claude:claude-opus-4-8:Claude Code
pull/3911/head
Siegfried Pammer 2 months ago committed by Siegfried Pammer
parent
commit
ffb39611b8
  1. 7
      ILSpy.AddIn.VS2022/ILSpyInstance.cs
  2. 129
      ILSpy.Tests/Commands/SingleInstanceTests.cs
  3. 61
      ILSpy/App.axaml.cs
  4. 6
      ILSpy/AppEnv/CommandLineArguments.cs
  5. 337
      ILSpy/AppEnv/SingleInstance.cs
  6. 14
      ILSpy/AppEnv/UiContext.cs
  7. 29
      ILSpy/Program.cs

7
ILSpy.AddIn.VS2022/ILSpyInstance.cs

@ -80,11 +80,16 @@ namespace ICSharpCode.ILSpy.AddIn @@ -80,11 +80,16 @@ namespace ICSharpCode.ILSpy.AddIn
argumentsToPass = $"@\"{filepath}\"";
}
// Target only this bundled ILSpy build: --instanceid names the exe we launch, so ILSpy
// reuses a running instance iff it is that same executable, and otherwise opens its own
// window instead of joining a different ILSpy the user may have open.
string instanceId = Path.GetFullPath(ilSpyExe);
var process = new Process() {
StartInfo = new ProcessStartInfo() {
FileName = ilSpyExe,
UseShellExecute = false,
Arguments = argumentsToPass
Arguments = $"--instanceid \"{instanceId}\" {argumentsToPass}"
}
};
process.Start();

129
ILSpy.Tests/Commands/SingleInstanceTests.cs

@ -0,0 +1,129 @@ @@ -0,0 +1,129 @@
// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.IO;
using AwesomeAssertions;
using ICSharpCode.ILSpy.AppEnv;
using NUnit.Framework;
namespace ICSharpCode.ILSpy.Tests;
[TestFixture]
public class SingleInstanceTests
{
[Test]
public void InstanceId_Option_Is_Parsed()
{
CommandLineArguments.Create(new[] { "--instanceid", "Foo" }).InstanceId.Should().Be("Foo");
}
[Test]
public void InstanceId_Defaults_To_Null_When_Absent()
{
CommandLineArguments.Create(Array.Empty<string>()).InstanceId.Should().BeNull();
}
[Test]
public void NewInstance_Option_Forces_SingleInstance_False()
{
// --newinstance is what disables single-instance for a single launch; lock in the mapping
// now that a consumer finally reads CommandLineArguments.SingleInstance.
CommandLineArguments.Create(new[] { "--newinstance" }).SingleInstance.Should().BeFalse();
CommandLineArguments.Create(Array.Empty<string>()).SingleInstance.Should().BeNull();
}
[Test]
public void GetInstanceName_Is_Deterministic_And_Contains_No_Path_Separators()
{
// The mutex/pipe namespace is derived from machine + user only -- no executable location --
// so every launch of the same user shares one instance regardless of which exe path it came
// from. The name feeds a Mutex/pipe identifier, so it must carry no path separators.
var name = SingleInstance.GetInstanceName();
name.Should().Be(SingleInstance.GetInstanceName());
name.Should().StartWith("ILSpy.");
name.Should().NotContain("/");
name.Should().NotContain("\\");
}
[Test]
public void ShouldReuse_When_No_Id_Requested()
{
// A launcher without --instanceid (Explorer "Open with", a plain CLI launch) reuses whatever
// instance is running, regardless of that instance's identity.
SingleInstance.ShouldReuse(null, null, "/opt/ilspy/ILSpy").Should().BeTrue();
SingleInstance.ShouldReuse("", "launch-id", "/opt/ilspy/ILSpy").Should().BeTrue();
}
[Test]
public void ShouldReuse_When_Requested_Id_Matches_Running_Launch_Id()
{
SingleInstance.ShouldReuse("Foo", "Foo", "/opt/ilspy/ILSpy").Should().BeTrue();
}
[Test]
public void ShouldReuse_When_Requested_Id_Matches_Running_Executable_Path()
{
// The only-AddIn-installed case: VS requests its bundled exe path, and the running instance
// was started WITHOUT an id (e.g. by a shell "Open with ILSpy" on that same exe). It must
// still match, because the running instance reports the executable it actually is.
SingleInstance.ShouldReuse("/opt/ilspy/ILSpy", null, "/opt/ilspy/ILSpy").Should().BeTrue();
}
[Test]
public void ShouldReuse_False_When_Requested_Id_Matches_Neither()
{
SingleInstance.ShouldReuse("/opt/other/ILSpy", "Foo", "/opt/ilspy/ILSpy").Should().BeFalse();
}
[Test]
public void FullyQualifyPath_Roots_An_Existing_Relative_File_Against_The_Base_Directory()
{
var dir = Path.Combine(Path.GetTempPath(), "ILSpySingleInstance_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
var file = Path.Combine(dir, "some.dll");
File.WriteAllText(file, "");
SingleInstance.FullyQualifyPath("some.dll", dir).Should().Be(file);
}
finally
{
Directory.Delete(dir, recursive: true);
}
}
[Test]
public void FullyQualifyPath_Leaves_Non_File_Tokens_Unchanged()
{
var dir = Path.GetTempPath();
var missing = "definitely-not-a-file-" + Guid.NewGuid().ToString("N");
var rooted = Path.Combine(dir, "does-not-exist-" + Guid.NewGuid().ToString("N") + ".dll");
// An option flag, a value that is not an existing file, and an already-rooted path all pass
// through untouched so the receiving instance re-parses them exactly as typed.
SingleInstance.FullyQualifyPath("--navigateto", dir).Should().Be("--navigateto");
SingleInstance.FullyQualifyPath(missing, dir).Should().Be(missing);
SingleInstance.FullyQualifyPath(rooted, dir).Should().Be(rooted);
}
}

61
ILSpy/App.axaml.cs

@ -20,14 +20,17 @@ using System; @@ -20,14 +20,17 @@ using System;
using System.Composition.Hosting;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using ICSharpCode.ILSpyX.Settings;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Themes;
using ICSharpCode.ILSpy.Views;
@ -57,22 +60,7 @@ namespace ICSharpCode.ILSpy @@ -57,22 +60,7 @@ namespace ICSharpCode.ILSpy
CommandLineArguments = CommandLineArguments.Create(Environment.GetCommandLineArgs()[1..]);
ILSpySettings.SettingsFilePathProvider = () => {
if (App.CommandLineArguments.ConfigFile != null)
return App.CommandLineArguments.ConfigFile;
var assemblyLocation = typeof(MainWindow).Assembly.Location;
if (!String.IsNullOrWhiteSpace(assemblyLocation))
{
var assemblyDirectory = Path.GetDirectoryName(assemblyLocation);
Debug.Assert(assemblyDirectory != null);
string localPath = Path.Combine(assemblyDirectory, "ILSpy.xml");
if (File.Exists(localPath))
return localPath;
}
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ICSharpCode", "ILSpy.xml");
};
ConfigureSettingsFilePathProvider(CommandLineArguments);
try
{
@ -125,11 +113,52 @@ namespace ICSharpCode.ILSpy @@ -125,11 +113,52 @@ namespace ICSharpCode.ILSpy
catch { /* persistence must never block shutdown */ }
Composition?.Dispose();
};
// A second launch forwards its command-line arguments to this instance over the
// single-instance pipe (see Program.Main); open them in this window rather than
// letting a second process start. No-op when single-instance is not in force.
SingleInstance.NewInstanceDetected += OnNewInstanceDetected;
}
base.OnFrameworkInitializationCompleted();
}
// Resolves where ILSpy.xml is loaded from. Shared by normal startup and the single-instance
// gate in Program.Main, which reads the settings before Avalonia starts.
internal static void ConfigureSettingsFilePathProvider(CommandLineArguments commandLineArguments)
{
ILSpySettings.SettingsFilePathProvider = () => {
if (commandLineArguments.ConfigFile != null)
return commandLineArguments.ConfigFile;
var assemblyLocation = typeof(MainWindow).Assembly.Location;
if (!String.IsNullOrWhiteSpace(assemblyLocation))
{
var assemblyDirectory = Path.GetDirectoryName(assemblyLocation);
Debug.Assert(assemblyDirectory != null);
string localPath = Path.Combine(assemblyDirectory, "ILSpy.xml");
if (File.Exists(localPath))
return localPath;
}
return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ICSharpCode", "ILSpy.xml");
};
}
// Raised on the single-instance listener thread; marshal to the UI thread before touching
// the model or the window.
static void OnNewInstanceDetected(string[] forwardedArguments)
=> Dispatcher.UIThread.Post(() => HandleForwardedArgumentsAsync(forwardedArguments).HandleExceptions());
static async Task HandleForwardedArgumentsAsync(string[] forwardedArguments)
{
var args = CommandLineArguments.Create(forwardedArguments);
if (Composition?.GetExport<AssemblyTreeModel>() is { } assemblyTreeModel)
await assemblyTreeModel.HandleCommandLineArgumentsAsync(args);
if (!args.NoActivate)
UiContext.ActivateMainWindow();
}
// Effective UI culture is process-wide. Setting it on startup means a changed CurrentCulture
// only takes effect after restart, matching the WPF behaviour.
static void ApplyCulture(string? culture)

6
ILSpy/AppEnv/CommandLineArguments.cs

@ -35,6 +35,7 @@ namespace ICSharpCode.ILSpy.AppEnv @@ -35,6 +35,7 @@ namespace ICSharpCode.ILSpy.AppEnv
public string Language;
public bool NoActivate;
public string ConfigFile;
public string InstanceId;
public CommandLineApplication ArgumentsParser { get; }
@ -79,6 +80,10 @@ namespace ICSharpCode.ILSpy.AppEnv @@ -79,6 +80,10 @@ namespace ICSharpCode.ILSpy.AppEnv
"Do not activate the existing ILSpy instance.",
CommandOptionType.NoValue);
var oInstanceId = app.Option<string>("--instanceid <NAME>",
"Only reuse a running ILSpy instance whose identity (its executable, or its own --instanceid) matches NAME; otherwise start a separate instance.",
CommandOptionType.SingleValue);
var files = app.Argument("Assemblies", "Assemblies to load", multipleValues: true);
app.Parse(arguments.ToArray());
@ -90,6 +95,7 @@ namespace ICSharpCode.ILSpy.AppEnv @@ -90,6 +95,7 @@ namespace ICSharpCode.ILSpy.AppEnv
instance.Search = oSearch.ParsedValue;
instance.Language = oLanguage.ParsedValue;
instance.ConfigFile = oConfig.ParsedValue;
instance.InstanceId = oInstanceId.ParsedValue;
if (oNoActivate.HasValue())
instance.NoActivate = true;

337
ILSpy/AppEnv/SingleInstance.cs

@ -0,0 +1,337 @@ @@ -0,0 +1,337 @@
// Copyright (c) 2026 Siegfried Pammer
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify, merge,
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
// to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or
// substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
using System;
using System.Buffers.Binary;
using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using ICSharpCode.ILSpy.Options;
using ICSharpCode.ILSpyX.Settings;
namespace ICSharpCode.ILSpy.AppEnv
{
/// <summary>
/// Cross-platform single-instance coordination. The first launch for a user takes a named
/// <see cref="Mutex"/> and listens on a named pipe; a later launch forwards its command-line
/// arguments over that pipe and exits, so the running window handles them instead of a second
/// window opening.
///
/// The mutex/pipe namespace is derived from machine + user only -- never the executable location
/// -- so any launcher (Explorer "Open with", the CLI, a debug build at a different path) reuses
/// the running instance. A launcher that needs a specific instance passes <c>--instanceid</c>;
/// that value is a runtime reuse filter (see <see cref="ShouldReuse"/>), not part of the
/// namespace, so it never re-introduces the location-partitioning it replaces.
/// </summary>
public static class SingleInstance
{
static readonly object gate = new();
static Mutex? heldMutex;
static string? ownLaunchId;
static Action<string[]>? newInstanceHandler;
static string[]? bufferedArgs;
/// <summary>
/// Raised (on a background thread) when another instance forwarded its arguments and the
/// running instance accepted them. If no handler is attached yet -- the pipe server can
/// outrace application startup -- the last payload is buffered and replayed on subscribe.
/// </summary>
public static event Action<string[]> NewInstanceDetected {
add {
string[]? replay = null;
lock (gate)
{
newInstanceHandler += value;
if (bufferedArgs is { } pending)
{
bufferedArgs = null;
replay = pending;
}
}
// Invoke the replay outside the lock: the handler must never run arbitrary code
// (or re-enter SingleInstance) while gate is held.
if (replay != null)
value(replay);
}
remove {
lock (gate)
{
newInstanceHandler -= value;
}
}
}
/// <summary>
/// The mutex/pipe name shared by every launch of the current user, independent of the
/// executable's location. Contains no path separators so it is a valid mutex/pipe identifier.
/// </summary>
public static string GetInstanceName()
{
var material = Encoding.UTF8.GetBytes(Environment.MachineName + "\n" + Environment.UserName);
return "ILSpy." + Convert.ToHexString(SHA256.HashData(material));
}
/// <summary>
/// The identity a launcher can target with <c>--instanceid</c>: the full path of the running
/// executable. Uses <see cref="Environment.ProcessPath"/> (valid even in a single-file
/// bundle, unlike <c>Assembly.Location</c>); empty when it cannot be determined.
/// </summary>
public static string SelfExecutableId()
{
var path = Environment.ProcessPath;
if (string.IsNullOrWhiteSpace(path))
return string.Empty;
try
{
return Path.GetFullPath(path);
}
catch (Exception)
{
return path;
}
}
/// <summary>
/// Decides whether a launch requesting <paramref name="requestedId"/> may reuse a running
/// instance whose own launch id is <paramref name="runningLaunchId"/> and whose executable
/// identity is <paramref name="runningExecutableId"/>. Reuse when nothing specific was
/// requested, or the request matches either the running instance's launch id or the
/// executable it actually is.
/// </summary>
public static bool ShouldReuse(string? requestedId, string? runningLaunchId, string runningExecutableId)
{
if (string.IsNullOrEmpty(requestedId))
return true;
return IdentityEquals(requestedId, runningLaunchId)
|| IdentityEquals(requestedId, runningExecutableId);
}
static bool IdentityEquals(string? a, string? b)
{
if (a is null || b is null)
return false;
// --instanceid is typically an executable path, so compare the way the platform compares
// paths: case-insensitive on Windows, case-sensitive elsewhere.
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
return string.Equals(a, b, comparison);
}
/// <summary>
/// Resolves a forwarded command-line token to an absolute path when it names an existing file
/// relative to the sending process's working directory; leaves option flags, non-file values,
/// and already-rooted paths untouched. The running instance has a different working directory,
/// so relative assembly paths must be qualified by the sender before forwarding.
/// </summary>
public static string FullyQualifyPath(string arg) => FullyQualifyPath(arg, Environment.CurrentDirectory);
internal static string FullyQualifyPath(string arg, string baseDirectory)
{
if (string.IsNullOrEmpty(arg) || Path.IsPathRooted(arg))
return arg;
try
{
var full = Path.GetFullPath(arg, baseDirectory);
if (File.Exists(full))
return full;
}
catch (Exception)
{
// Not a usable path token (e.g. an option value with characters illegal in a path);
// forward it verbatim so the receiver re-parses it exactly as typed.
}
return arg;
}
/// <summary>
/// Coordinates single-instance startup. Returns <c>true</c> when this process should start its
/// window (it is the first instance, single-instance is disabled, or the running instance was
/// dead or declined the hand-off); returns <c>false</c> when the arguments were forwarded to a
/// running instance and this process should exit.
/// </summary>
public static bool TrySignalFirstInstance(CommandLineArguments args)
{
bool forceSingleInstance = (args.SingleInstance ?? true) && !ReadAllowMultipleInstances();
if (!forceSingleInstance)
return true;
try
{
string name = GetInstanceName();
var mutex = new Mutex(initiallyOwned: false, @"Global\" + name);
// Acquiring the mutex -- not merely observing that it exists -- is what makes this
// process the owner. A crashed previous owner leaves it abandoned, so the next waiter
// acquires it (AbandonedMutexException) and legitimately takes over. This is why
// hand-off failure below never falls through to BecomeServer: only true ownership,
// established here, starts a pipe server, so a startup race or a transient pipe
// failure can never leave two processes both serving.
bool owned;
try
{
owned = mutex.WaitOne(0);
}
catch (AbandonedMutexException)
{
owned = true;
}
if (owned)
{
BecomeServer(mutex, name, args.InstanceId);
return true;
}
// A live instance owns the mutex: hand off to it. If it accepts, exit; if it declines
// (build-affinity mismatch) or its listener is transiently unreachable, start a normal
// window -- without ever becoming a second server we do not own.
bool accepted = ForwardToRunningInstance(name, args.InstanceId);
mutex.Dispose();
return !accepted;
}
catch (Exception ex)
{
Trace.TraceWarning("SingleInstance: falling back to a normal launch. " + ex);
return true;
}
}
static void BecomeServer(Mutex mutex, string name, string? launchId)
{
heldMutex = mutex;
ownLaunchId = launchId;
var thread = new Thread(() => ServerLoop(name)) {
Name = "ILSpy single-instance listener",
IsBackground = true
};
thread.Start();
}
static void ServerLoop(string name)
{
while (true)
{
try
{
using var server = new NamedPipeServerStream(name, PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.CurrentUserOnly);
server.WaitForConnection();
HandleConnection(server);
}
catch (Exception ex)
{
Trace.TraceWarning("SingleInstance listener: " + ex);
Thread.Sleep(100);
}
}
}
static void HandleConnection(NamedPipeServerStream server)
{
var request = JsonSerializer.Deserialize<ForwardRequest>(ReadMessage(server));
bool accepted = request != null
&& ShouldReuse(request.RequestedId, ownLaunchId, SelfExecutableId());
WriteMessage(server, JsonSerializer.SerializeToUtf8Bytes(new ForwardResponse(accepted)));
server.Flush();
if (accepted && request != null)
RaiseNewInstance(request.Args);
}
static void RaiseNewInstance(string[] args)
{
Action<string[]>? handler;
lock (gate)
{
handler = newInstanceHandler;
if (handler is null)
{
bufferedArgs = args;
return;
}
}
handler(args);
}
// Returns true if the running instance accepted the forwarded arguments (so this process
// should exit); false if it declined or could not be reached (so this process starts a
// normal window).
static bool ForwardToRunningInstance(string name, string? requestedId)
{
try
{
using var client = new NamedPipeClientStream(".", name, PipeDirection.InOut, PipeOptions.CurrentUserOnly);
client.Connect(2000);
var forwarded = Environment.GetCommandLineArgs().Skip(1).Select(FullyQualifyPath).ToArray();
WriteMessage(client, JsonSerializer.SerializeToUtf8Bytes(new ForwardRequest(requestedId, forwarded)));
client.Flush();
var response = JsonSerializer.Deserialize<ForwardResponse>(ReadMessage(client));
return response?.Accepted == true;
}
catch (Exception ex)
{
Trace.TraceWarning("SingleInstance hand-off failed: " + ex);
return false;
}
}
static bool ReadAllowMultipleInstances()
{
try
{
var settings = new MiscSettings();
settings.LoadFromXml(ILSpySettings.Load()["MiscSettings"]);
return settings.AllowMultipleInstances;
}
catch (Exception)
{
// A missing or unreadable settings file must not force multiple instances.
return false;
}
}
static void WriteMessage(Stream stream, byte[] payload)
{
Span<byte> lengthPrefix = stackalloc byte[sizeof(int)];
BinaryPrimitives.WriteInt32LittleEndian(lengthPrefix, payload.Length);
stream.Write(lengthPrefix);
stream.Write(payload);
}
static byte[] ReadMessage(Stream stream)
{
Span<byte> lengthPrefix = stackalloc byte[sizeof(int)];
stream.ReadExactly(lengthPrefix);
int length = BinaryPrimitives.ReadInt32LittleEndian(lengthPrefix);
if (length < 0 || length > 16 * 1024 * 1024)
throw new InvalidDataException("SingleInstance: message length out of range.");
var payload = new byte[length];
stream.ReadExactly(payload);
return payload;
}
sealed record ForwardRequest(string? RequestedId, string[] Args);
sealed record ForwardResponse(bool Accepted);
}
}

14
ILSpy/AppEnv/UiContext.cs

@ -38,6 +38,20 @@ namespace ICSharpCode.ILSpy.AppEnv @@ -38,6 +38,20 @@ namespace ICSharpCode.ILSpy.AppEnv
/// <summary>The main window's clipboard, or null when unavailable.</summary>
public static IClipboard? Clipboard => MainWindow?.Clipboard;
/// <summary>
/// Brings the main window to the foreground, restoring it if minimized. Used when a second
/// launch forwards its arguments to this instance. Best-effort: a window manager may still
/// deny the focus change (e.g. X11 focus-stealing prevention). Call on the UI thread.
/// </summary>
public static void ActivateMainWindow()
{
if (MainWindow is not { } window)
return;
if (window.WindowState == WindowState.Minimized)
window.WindowState = WindowState.Normal;
window.Activate();
}
/// <summary>Requests application shutdown on the desktop lifetime; a no-op elsewhere.</summary>
public static void Shutdown()
=> (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown();

29
ILSpy/Program.cs

@ -20,6 +20,8 @@ using System; @@ -20,6 +20,8 @@ using System;
using Avalonia;
using ICSharpCode.ILSpy.AppEnv;
namespace ICSharpCode.ILSpy
{
sealed class Program
@ -28,8 +30,31 @@ namespace ICSharpCode.ILSpy @@ -28,8 +30,31 @@ namespace ICSharpCode.ILSpy
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
public static void Main(string[] args)
{
// Single-instance gate runs before Avalonia starts: a second launch forwards its
// arguments to the running instance over a named pipe and exits here, so no second
// window opens.
if (!ShouldStartNewInstance(args))
return;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
static bool ShouldStartNewInstance(string[] args)
{
try
{
var commandLineArguments = CommandLineArguments.Create(args);
App.ConfigureSettingsFilePathProvider(commandLineArguments);
return SingleInstance.TrySignalFirstInstance(commandLineArguments);
}
catch (Exception)
{
// Single-instance coordination must never prevent a normal launch.
return true;
}
}
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()

Loading…
Cancel
Save