diff --git a/AGENTS.md b/AGENTS.md index c573c25..865abdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/RadialActions.Tests/Services/SingleInstanceTests.cs b/RadialActions.Tests/Services/SingleInstanceTests.cs new file mode 100644 index 0000000..2ad954f --- /dev/null +++ b/RadialActions.Tests/Services/SingleInstanceTests.cs @@ -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); + } +} diff --git a/RadialActions/App.xaml b/RadialActions/App.xaml index 190826d..7194579 100644 --- a/RadialActions/App.xaml +++ b/RadialActions/App.xaml @@ -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"> diff --git a/RadialActions/App.xaml.cs b/RadialActions/App.xaml.cs index a39a5d8..f0deae9 100644 --- a/RadialActions/App.xaml.cs +++ b/RadialActions/App.xaml.cs @@ -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; + /// /// The main executable file of the application. /// @@ -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); } /// diff --git a/RadialActions/MainWindow.xaml.cs b/RadialActions/MainWindow.xaml.cs index 135bc9f..e296b0a 100644 --- a/RadialActions/MainWindow.xaml.cs +++ b/RadialActions/MainWindow.xaml.cs @@ -105,6 +105,15 @@ public void OpenSettingsWindow(string tabIndex) OpenSettingsWindow(index); } + /// + /// Opens Settings when the app is launched again while it's running, since the user is looking for it. + /// + 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}"); diff --git a/RadialActions/Services/SingleInstance.cs b/RadialActions/Services/SingleInstance.cs new file mode 100644 index 0000000..a18137b --- /dev/null +++ b/RadialActions/Services/SingleInstance.cs @@ -0,0 +1,71 @@ +using System.Runtime.InteropServices; + +namespace RadialActions; + +/// +/// 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. +/// +internal sealed class SingleInstance : IDisposable +{ + private const int ASFW_ANY = -1; + + private readonly EventWaitHandle _showRequested; + private RegisteredWaitHandle _showRequestRegistration; + + /// A session-wide name such as Local\RadialActions, shared by every copy wherever it's installed. + 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"); + } + } + + /// + /// True when no other copy is running, so this one should start. + /// + public bool IsFirst { get; } + + /// + /// Asks the first copy to show itself, passing it the right to come to the foreground that this newly launched copy holds. + /// + public void RequestShow() + { + if (_showRequested == null) + return; + + AllowSetForegroundWindow(ASFW_ANY); + _showRequested.Set(); + } + + /// + /// Calls on a thread pool thread each time a later launch calls . + /// + 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); +}