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
7 changes: 6 additions & 1 deletion ILSpy.AddIn.VS2022/ILSpyInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
129 changes: 129 additions & 0 deletions ILSpy.Tests/Commands/SingleInstanceTests.cs
Original file line number Diff line number Diff line change
@@ -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 changes: 45 additions & 16 deletions ILSpy/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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<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)
Expand Down
6 changes: 6 additions & 0 deletions ILSpy/AppEnv/CommandLineArguments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public sealed class CommandLineArguments
public string Language;
public bool NoActivate;
public string ConfigFile;
public string InstanceId;

public CommandLineApplication ArgumentsParser { get; }

Expand Down Expand Up @@ -79,6 +80,10 @@ public static CommandLineArguments Create(IEnumerable<string> arguments)
"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());
Expand All @@ -90,6 +95,7 @@ public static CommandLineArguments Create(IEnumerable<string> arguments)
instance.Search = oSearch.ParsedValue;
instance.Language = oLanguage.ParsedValue;
instance.ConfigFile = oConfig.ParsedValue;
instance.InstanceId = oInstanceId.ParsedValue;

if (oNoActivate.HasValue())
instance.NoActivate = true;
Expand Down
Loading
Loading