diff --git a/ILSpy.AddIn.VS2022/ILSpyInstance.cs b/ILSpy.AddIn.VS2022/ILSpyInstance.cs index ce475a117a..9be26e8ed9 100644 --- a/ILSpy.AddIn.VS2022/ILSpyInstance.cs +++ b/ILSpy.AddIn.VS2022/ILSpyInstance.cs @@ -80,11 +80,16 @@ public void Start() 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(); diff --git a/ILSpy.Tests/Commands/SingleInstanceTests.cs b/ILSpy.Tests/Commands/SingleInstanceTests.cs new file mode 100644 index 0000000000..3f34857d0f --- /dev/null +++ b/ILSpy.Tests/Commands/SingleInstanceTests.cs @@ -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()).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()).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); + } +} diff --git a/ILSpy/App.axaml.cs b/ILSpy/App.axaml.cs index bbe7ea8cbe..ba66e8aacd 100644 --- a/ILSpy/App.axaml.cs +++ b/ILSpy/App.axaml.cs @@ -20,14 +20,17 @@ 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 @@ public override void OnFrameworkInitializationCompleted() 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 @@ public override void OnFrameworkInitializationCompleted() 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() 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) diff --git a/ILSpy/AppEnv/CommandLineArguments.cs b/ILSpy/AppEnv/CommandLineArguments.cs index 491fe55a42..3b559cdb42 100644 --- a/ILSpy/AppEnv/CommandLineArguments.cs +++ b/ILSpy/AppEnv/CommandLineArguments.cs @@ -35,6 +35,7 @@ public sealed class CommandLineArguments public string Language; public bool NoActivate; public string ConfigFile; + public string InstanceId; public CommandLineApplication ArgumentsParser { get; } @@ -79,6 +80,10 @@ public static CommandLineArguments Create(IEnumerable arguments) "Do not activate the existing ILSpy instance.", CommandOptionType.NoValue); + var oInstanceId = app.Option("--instanceid ", + "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 @@ public static CommandLineArguments Create(IEnumerable arguments) instance.Search = oSearch.ParsedValue; instance.Language = oLanguage.ParsedValue; instance.ConfigFile = oConfig.ParsedValue; + instance.InstanceId = oInstanceId.ParsedValue; if (oNoActivate.HasValue()) instance.NoActivate = true; diff --git a/ILSpy/AppEnv/SingleInstance.cs b/ILSpy/AppEnv/SingleInstance.cs new file mode 100644 index 0000000000..b9517b525b --- /dev/null +++ b/ILSpy/AppEnv/SingleInstance.cs @@ -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 +{ + /// + /// Cross-platform single-instance coordination. The first launch for a user takes a named + /// 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 --instanceid; + /// that value is a runtime reuse filter (see ), not part of the + /// namespace, so it never re-introduces the location-partitioning it replaces. + /// + public static class SingleInstance + { + static readonly object gate = new(); + static Mutex? heldMutex; + static string? ownLaunchId; + static Action? newInstanceHandler; + static string[]? bufferedArgs; + + /// + /// 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. + /// + public static event Action 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; + } + } + } + + /// + /// 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. + /// + public static string GetInstanceName() + { + var material = Encoding.UTF8.GetBytes(Environment.MachineName + "\n" + Environment.UserName); + return "ILSpy." + Convert.ToHexString(SHA256.HashData(material)); + } + + /// + /// The identity a launcher can target with --instanceid: the full path of the running + /// executable. Uses (valid even in a single-file + /// bundle, unlike Assembly.Location); empty when it cannot be determined. + /// + public static string SelfExecutableId() + { + var path = Environment.ProcessPath; + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + try + { + return Path.GetFullPath(path); + } + catch (Exception) + { + return path; + } + } + + /// + /// Decides whether a launch requesting may reuse a running + /// instance whose own launch id is and whose executable + /// identity is . Reuse when nothing specific was + /// requested, or the request matches either the running instance's launch id or the + /// executable it actually is. + /// + 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); + } + + /// + /// 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. + /// + 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; + } + + /// + /// Coordinates single-instance startup. Returns true 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 false when the arguments were forwarded to a + /// running instance and this process should exit. + /// + 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(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? 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(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 lengthPrefix = stackalloc byte[sizeof(int)]; + BinaryPrimitives.WriteInt32LittleEndian(lengthPrefix, payload.Length); + stream.Write(lengthPrefix); + stream.Write(payload); + } + + static byte[] ReadMessage(Stream stream) + { + Span 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); + } +} diff --git a/ILSpy/AppEnv/UiContext.cs b/ILSpy/AppEnv/UiContext.cs index b0997d390b..98c386232c 100644 --- a/ILSpy/AppEnv/UiContext.cs +++ b/ILSpy/AppEnv/UiContext.cs @@ -38,6 +38,20 @@ public static Window? MainWindow /// The main window's clipboard, or null when unavailable. public static IClipboard? Clipboard => MainWindow?.Clipboard; + /// + /// 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. + /// + public static void ActivateMainWindow() + { + if (MainWindow is not { } window) + return; + if (window.WindowState == WindowState.Minimized) + window.WindowState = WindowState.Normal; + window.Activate(); + } + /// Requests application shutdown on the desktop lifetime; a no-op elsewhere. public static void Shutdown() => (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.Shutdown(); diff --git a/ILSpy/Program.cs b/ILSpy/Program.cs index 3b76f29991..440b75b283 100644 --- a/ILSpy/Program.cs +++ b/ILSpy/Program.cs @@ -20,6 +20,8 @@ using Avalonia; +using ICSharpCode.ILSpy.AppEnv; + namespace ICSharpCode.ILSpy { sealed class Program @@ -28,8 +30,31 @@ sealed class Program // 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()