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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ For documentation-only changes, explain why build or test commands were skipped.

- Radial Actions is a lightweight Windows utility opened by a global hotkey. Startup, hotkey registration, tray behavior, and menu display should stay fast and predictable.
- Opening the menu must not wait on a pie rebuild. `PieControl` builds at idle while hidden and only builds at render priority when a refresh is still pending at open.
- Only one copy runs per sign-in session. A later launch from Start, the installer, or the Run key asks the running copy to open Settings and exits before building any window (`SingleInstance`).
- Running a slice must not block the UI thread. `MainWindow` starts the dismiss first and executes the action on a worker thread; only the failure notification returns to the UI thread.
- User-configured actions must not execute during tests, settings load, preview rendering, or validation.
- Invalid or stale settings should normalize to safe defaults instead of crashing on startup.
Expand Down
63 changes: 63 additions & 0 deletions RadialActions.Tests/Services/SingleInstanceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Security.AccessControl;
using System.Security.Principal;

namespace RadialActions.Tests;

public class SingleInstanceTests
{
private static string UniqueName() => $@"Local\RadialActions.Tests.{Guid.NewGuid():N}";

[Fact]
public void FirstCopy_IsFirst_LaterCopyIsNot()
{
var name = UniqueName();

using var first = new SingleInstance(name);
using var second = new SingleInstance(name);

Assert.True(first.IsFirst);
Assert.False(second.IsFirst);
}

[Fact]
public void RequestShow_FromLaterCopy_NotifiesFirstCopy()
{
var name = UniqueName();
using var showRequested = new ManualResetEventSlim();
using var first = new SingleInstance(name);
first.ListenForShowRequests(showRequested.Set);

using (var second = new SingleInstance(name))
{
second.RequestShow();
}

Assert.True(showRequested.Wait(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken));
}

[Fact]
public void LaterCopyThatCannotOpenTheEvent_IsNotFirst_AndRequestShowAndDisposeAreNoOps()
{
var name = UniqueName();
var security = new EventWaitHandleSecurity();
security.AddAccessRule(new EventWaitHandleAccessRule(WindowsIdentity.GetCurrent().User, EventWaitHandleRights.Modify | EventWaitHandleRights.Synchronize, AccessControlType.Deny));
using var otherAccountCopy = EventWaitHandleAcl.Create(false, EventResetMode.AutoReset, name, out _, security);

var later = new SingleInstance(name);

Assert.False(later.IsFirst);
later.RequestShow();
later.Dispose();
}

[Fact]
public void AfterFirstCopyExits_NextCopyIsFirst()
{
var name = UniqueName();

new SingleInstance(name).Dispose();
using var next = new SingleInstance(name);

Assert.True(next.IsFirst);
}
}
2 changes: 1 addition & 1 deletion RadialActions/App.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:RadialActions"
StartupUri="MainWindow.xaml" ThemeMode="System">
ThemeMode="System">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
Expand Down
29 changes: 29 additions & 0 deletions RadialActions/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ namespace RadialActions;
[INotifyPropertyChanged]
public partial class App : Application
{
// Debug builds use their own name so they can run next to an installed copy.
#if DEBUG
private const string SingleInstanceName = @"Local\RadialActions.Debug";
#else
private const string SingleInstanceName = @"Local\RadialActions";
#endif

private SingleInstance _singleInstance;

/// <summary>
/// The main executable file of the application.
/// </summary>
Expand Down Expand Up @@ -50,6 +59,26 @@ protected override void OnStartup(StartupEventArgs e)

Log.Information($"Starting Radial Actions {FileVersionInfo.GetVersionInfo(MainFileInfo.FullName).FileVersion}");
Log.Information($"Runtime: {RuntimeInformation.FrameworkDescription} {RuntimeInformation.ProcessArchitecture}");

// Only the first copy builds the main window, since a second copy's tray icon would replace the running copy's and its hotkey would fail.
_singleInstance = new SingleInstance(SingleInstanceName);
if (!_singleInstance.IsFirst)
{
Log.Information("Radial Actions is already running; asking that copy to open Settings");
_singleInstance.RequestShow();
Shutdown();
return;
}

var mainWindow = new MainWindow();
_singleInstance.ListenForShowRequests(() => mainWindow.Dispatcher.InvokeAsync(mainWindow.OpenSettingsForRelaunch));
mainWindow.Show();
}

protected override void OnExit(ExitEventArgs e)
{
_singleInstance?.Dispose();
base.OnExit(e);
}

/// <summary>
Expand Down
9 changes: 9 additions & 0 deletions RadialActions/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ public void OpenSettingsWindow(string tabIndex)
OpenSettingsWindow(index);
}

/// <summary>
/// Opens Settings when the app is launched again while it's running, since the user is looking for it.
/// </summary>
public void OpenSettingsForRelaunch()
{
Log.Information("Launched again while running; opening Settings");
OpenSettingsWindow(GeneralTabIndex);
}

private void OpenSettingsWindow(int tabIndex)
{
Log.Debug($"Opening settings window to tab {tabIndex}");
Expand Down
71 changes: 71 additions & 0 deletions RadialActions/Services/SingleInstance.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using System.Runtime.InteropServices;

namespace RadialActions;

/// <summary>
/// Keeps one copy of the app per sign-in session. A later launch asks the first copy to show itself instead of adding a second tray icon that can't register the hotkey.
/// </summary>
internal sealed class SingleInstance : IDisposable
{
private const int ASFW_ANY = -1;

private readonly EventWaitHandle _showRequested;
private RegisteredWaitHandle _showRequestRegistration;

/// <param name="name">A session-wide name such as Local\RadialActions, shared by every copy wherever it's installed.</param>
public SingleInstance(string name)
{
try
{
_showRequested = new EventWaitHandle(false, EventResetMode.AutoReset, name, out var createdNew);
IsFirst = createdNew;
}
catch (UnauthorizedAccessException ex)
{
// A copy under another account in this session created the event, so this copy can't open it or ask that copy to show itself.
Log.Warning(ex, "Radial Actions is already running under another account");
}
}

/// <summary>
/// True when no other copy is running, so this one should start.
/// </summary>
public bool IsFirst { get; }

/// <summary>
/// Asks the first copy to show itself, passing it the right to come to the foreground that this newly launched copy holds.
/// </summary>
public void RequestShow()
{
if (_showRequested == null)
return;

AllowSetForegroundWindow(ASFW_ANY);
_showRequested.Set();
}

/// <summary>
/// Calls <paramref name="onShowRequested"/> on a thread pool thread each time a later launch calls <see cref="RequestShow"/>.
/// </summary>
public void ListenForShowRequests(Action onShowRequested)
{
ArgumentNullException.ThrowIfNull(onShowRequested);

_showRequestRegistration = ThreadPool.RegisterWaitForSingleObject(
_showRequested,
(_, _) => onShowRequested(),
null,
Timeout.Infinite,
executeOnlyOnce: false);
}

public void Dispose()
{
_showRequestRegistration?.Unregister(null);
_showRequested?.Dispose();
}

[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
}