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
33 changes: 27 additions & 6 deletions src/OneWare.Essentials/Helpers/StudioInstanceChannel.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
using System.IO.Pipes;
using System.Security.Cryptography;
using System.Text;

namespace OneWare.Essentials.Helpers;

/// <summary>
/// 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 (<c>--oneware-appdata-dir</c>, e.g. a portable
/// copy) gets its own channel, so it neither forwards to nor receives requests from the default instance.
/// </summary>
public sealed class StudioInstanceChannel
{
Expand Down Expand Up @@ -33,9 +36,12 @@ public StudioInstanceChannel(string lockFilePath, string pipeName)
}

/// <summary>
/// Channel used by OneWare Studio for the current user.
/// Channel of the OneWare Studio that uses the current <c>ONEWARE_APPDATA_DIR</c>
/// (set by <c>--oneware-appdata-dir</c>), or the default channel of the current user.
/// Read it after the startup arguments were applied.
/// </summary>
public static StudioInstanceChannel Default { get; } = CreateDefault();
public static StudioInstanceChannel Default =>
ForAppDataDirectory(Environment.GetEnvironmentVariable("ONEWARE_APPDATA_DIR"));

public string LockFilePath { get; }

Expand Down Expand Up @@ -242,14 +248,29 @@ private NamedPipeServerStream CreateServer()
PipeTransmissionMode.Byte, PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly);
}

private static StudioInstanceChannel CreateDefault()
/// <summary>
/// Channel of the Studio that stores its application data in <paramref name="appDataDirectory" />.
/// <c>null</c> or empty returns the default channel of the current user.
/// </summary>
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()
Expand Down
14 changes: 10 additions & 4 deletions studio/OneWare.Studio.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> PendingIpcMessages = new();
private static FileStream? _lockFileStream;
private static CancellationTokenSource? _ipcCancellation;
private static bool _ipcReady;

/// <summary>
/// The channel depends on --oneware-appdata-dir, so it is created after the startup arguments were applied.
/// </summary>
private static StudioInstanceChannel GetInstanceChannel() =>
_instanceChannel ??= StudioInstanceChannel.Default;

public static void ReleaseLock()
{
try
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
33 changes: 33 additions & 0 deletions tests/OneWare.Essentials.UnitTests/StudioInstanceChannelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Loading