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()
{