From e37ac05821417fab9d176ba35d6224084e054abb Mon Sep 17 00:00:00 2001 From: Hendrik Mennen Date: Fri, 6 Nov 2026 15:40:21 +0100 Subject: [PATCH] Separate Studio instance per --oneware-appdata-dir A Studio started with its own application data directory (e.g. a portable copy) now uses its own single-instance lock and IPC channel, so it neither forwards its launch to nor receives requests from the default instance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Helpers/StudioInstanceChannel.cs | 33 +++++++++++++++---- studio/OneWare.Studio.Desktop/Program.cs | 14 +++++--- .../StudioInstanceChannelTests.cs | 33 +++++++++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs b/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs index 300df472..10fd05f4 100644 --- a/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs +++ b/src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs @@ -1,4 +1,5 @@ using System.IO.Pipes; +using System.Security.Cryptography; using System.Text; namespace OneWare.Essentials.Helpers; @@ -6,6 +7,8 @@ namespace OneWare.Essentials.Helpers; /// /// Per-user single-instance lock and IPC channel of the running OneWare Studio. /// Used by the desktop app (lock + server) and by CLI modules or a second instance to talk to it. +/// A Studio started with its own application data directory (--oneware-appdata-dir, e.g. a portable +/// copy) gets its own channel, so it neither forwards to nor receives requests from the default instance. /// public sealed class StudioInstanceChannel { @@ -33,9 +36,12 @@ public StudioInstanceChannel(string lockFilePath, string pipeName) } /// - /// Channel used by OneWare Studio for the current user. + /// Channel of the OneWare Studio that uses the current ONEWARE_APPDATA_DIR + /// (set by --oneware-appdata-dir), or the default channel of the current user. + /// Read it after the startup arguments were applied. /// - public static StudioInstanceChannel Default { get; } = CreateDefault(); + public static StudioInstanceChannel Default => + ForAppDataDirectory(Environment.GetEnvironmentVariable("ONEWARE_APPDATA_DIR")); public string LockFilePath { get; } @@ -242,14 +248,29 @@ private NamedPipeServerStream CreateServer() PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); } - private static StudioInstanceChannel CreateDefault() + /// + /// Channel of the Studio that stores its application data in . + /// null or empty returns the default channel of the current user. + /// + public static StudioInstanceChannel ForAppDataDirectory(string? appDataDirectory) { + var suffix = string.IsNullOrWhiteSpace(appDataDirectory) ? "" : "-" + HashDirectory(appDataDirectory); var directory = GetUserRuntimeDirectory(); var pipeName = OperatingSystem.IsWindows() - ? $"oneware-studio-ipc-{SanitizeName(Environment.UserName)}" - : Path.Combine(directory, "ipc.sock"); + ? $"oneware-studio-ipc-{SanitizeName(Environment.UserName)}{suffix}" + : Path.Combine(directory, $"ipc{suffix}.sock"); + + return new StudioInstanceChannel(Path.Combine(directory, $"oneware-studio{suffix}.lock"), pipeName); + } + + private static string HashDirectory(string directory) + { + var path = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + if (OperatingSystem.IsWindows()) + path = path.ToUpperInvariant(); - return new StudioInstanceChannel(Path.Combine(directory, "oneware-studio.lock"), pipeName); + // Short enough for the Unix socket path limit, long enough to keep distinct folders apart. + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(path)))[..16].ToLowerInvariant(); } private static string GetUserRuntimeDirectory() diff --git a/studio/OneWare.Studio.Desktop/Program.cs b/studio/OneWare.Studio.Desktop/Program.cs index 15522121..799b2261 100644 --- a/studio/OneWare.Studio.Desktop/Program.cs +++ b/studio/OneWare.Studio.Desktop/Program.cs @@ -28,12 +28,18 @@ namespace OneWare.Studio.Desktop; internal abstract class Program { - private static readonly StudioInstanceChannel InstanceChannel = StudioInstanceChannel.Default; + private static StudioInstanceChannel? _instanceChannel; private static readonly Queue PendingIpcMessages = new(); private static FileStream? _lockFileStream; private static CancellationTokenSource? _ipcCancellation; private static bool _ipcReady; + /// + /// The channel depends on --oneware-appdata-dir, so it is created after the startup arguments were applied. + /// + private static StudioInstanceChannel GetInstanceChannel() => + _instanceChannel ??= StudioInstanceChannel.Default; + public static void ReleaseLock() { try @@ -106,7 +112,7 @@ private static bool TryBecomePrimaryInstance() { try { - _lockFileStream = InstanceChannel.TryAcquirePrimaryLock(); + _lockFileStream = GetInstanceChannel().TryAcquirePrimaryLock(); if (_lockFileStream == null) { Console.WriteLine("Another instance is already running."); @@ -240,7 +246,7 @@ public static int Main(string[] args) ?? Environment.GetEnvironmentVariable("ONEWARE_OPEN_PATH") ?? StudioInstanceChannel.FormatCommand(StudioInstanceChannel.ActivateCommand); - if (InstanceChannel.SendMessageAsync(message).GetAwaiter().GetResult()) + if (GetInstanceChannel().SendMessageAsync(message).GetAwaiter().GetResult()) { Console.WriteLine("Request forwarded successfully."); return 0; @@ -253,7 +259,7 @@ public static int Main(string[] args) // We are the primary instance - start IPC server _ipcCancellation = new CancellationTokenSource(); var ipcToken = _ipcCancellation.Token; - _ = Task.Run(() => InstanceChannel.RunServerAsync(ReceiveIpcMessage, + _ = Task.Run(() => GetInstanceChannel().RunServerAsync(ReceiveIpcMessage, ex => Console.WriteLine($"IPC Server error: {ex.Message}"), ipcToken)); var appBuilder = BuildAvaloniaApp(); diff --git a/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs b/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs index cb6d9ccb..fc95a178 100644 --- a/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs +++ b/tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs @@ -59,6 +59,39 @@ public void TryParseCommand_RejectsNonCommands(string message) Assert.False(StudioInstanceChannel.TryParseCommand(message, out _, out _)); } + [Fact] + public void ForAppDataDirectory_WithoutDirectory_ReturnsUserChannel() + { + var channel = StudioInstanceChannel.ForAppDataDirectory(null); + + Assert.Equal("oneware-studio.lock", Path.GetFileName(channel.LockFilePath)); + Assert.Equal(channel.PipeName, StudioInstanceChannel.ForAppDataDirectory(" ").PipeName); + } + + [Fact] + public void ForAppDataDirectory_SeparatesDirectories() + { + var user = StudioInstanceChannel.ForAppDataDirectory(null); + var first = StudioInstanceChannel.ForAppDataDirectory(Path.Combine(_directory, "a")); + var second = StudioInstanceChannel.ForAppDataDirectory(Path.Combine(_directory, "b")); + + Assert.NotEqual(user.LockFilePath, first.LockFilePath); + Assert.NotEqual(user.PipeName, first.PipeName); + Assert.NotEqual(first.LockFilePath, second.LockFilePath); + Assert.NotEqual(first.PipeName, second.PipeName); + } + + [Fact] + public void ForAppDataDirectory_IgnoresTrailingSeparator() + { + var path = Path.Combine(_directory, "portable"); + var first = StudioInstanceChannel.ForAppDataDirectory(path); + var second = StudioInstanceChannel.ForAppDataDirectory(path + Path.DirectorySeparatorChar); + + Assert.Equal(first.LockFilePath, second.LockFilePath); + Assert.Equal(first.PipeName, second.PipeName); + } + [Fact] public void Lock_IsDetectedWhileHeldAndReleasedOnDispose() {