From 88dd931cb4a053b60146118f141f5dad64b6f743 Mon Sep 17 00:00:00 2001 From: Aleksandr Neychev Date: Thu, 13 Aug 2026 21:20:03 +0000 Subject: [PATCH] added sending closing event message to settings windows (#7) Reviewed-on: https://git.alrakis.kz/alrakis/cursor-lang/pulls/7 Co-authored-by: Aleksandr Neychev --- CursorLang.Agent.Tests/EndToEndTests.cs | 28 +++++ CursorLang.Agent/Agent.cs | 9 +- .../Services/SettingsCloseSignalTests.cs | 115 ++++++++++++++++++ .../Services/SettingsCloseSignal.cs | 98 +++++++++++++++ CursorLang.Settings/App.xaml.cs | 32 +++++ README.RU.md | 16 ++- README.md | 13 +- 7 files changed, 303 insertions(+), 8 deletions(-) create mode 100644 CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs create mode 100644 CursorLang.Core/Services/SettingsCloseSignal.cs diff --git a/CursorLang.Agent.Tests/EndToEndTests.cs b/CursorLang.Agent.Tests/EndToEndTests.cs index d79e5b2..28d874a 100644 --- a/CursorLang.Agent.Tests/EndToEndTests.cs +++ b/CursorLang.Agent.Tests/EndToEndTests.cs @@ -133,6 +133,34 @@ public sealed partial class EndToEndTests Assert.False(launch.Process.HasExited); } + /// + /// "Exit" in the tray menu ends the application whole: the settings window goes with + /// the agent instead of staying on the screen belonging to nothing. + /// + /// + /// The menu itself is out of reach of a test — it is a TrackPopupMenuEx menu + /// with a modal loop of its own — so what is checked is the request the menu makes. + /// The settings window here is the real one, started by the agent, and it must be + /// listening by the time it is on the screen. + /// + [Fact] + public void The_agents_exit_closes_the_settings_window() + { + using Launch launch = Launch.Start(); + launch.WaitForSettingsWindow(); + + Process settings = Launch.SettingsProcesses().Single(); + try + { + Assert.True(SettingsCloseSignal.RequestClose(), "the settings window was not listening"); + Assert.True(settings.WaitForExit(ExitTimeout), "the settings window outlived the agent's exit"); + } + finally + { + settings.Dispose(); + } + } + /// A started application that shuts down together with the check. private sealed class Launch : IDisposable { diff --git a/CursorLang.Agent/Agent.cs b/CursorLang.Agent/Agent.cs index 6c95f70..c114b9b 100644 --- a/CursorLang.Agent/Agent.cs +++ b/CursorLang.Agent/Agent.cs @@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open(); - private void OnExitRequested(object? sender, EventArgs e) => _window.Quit(); + // "Exit" means the application, not just the background half of it. A settings + // window left open would outlive the tray icon it was opened from, so it is asked + // to close first — it may be the very window the user is looking at + private void OnExitRequested(object? sender, EventArgs e) + { + SettingsCloseSignal.RequestClose(); + _window.Quit(); + } } diff --git a/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs b/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs new file mode 100644 index 0000000..ceeb4de --- /dev/null +++ b/CursorLang.Core.Tests/Services/SettingsCloseSignalTests.cs @@ -0,0 +1,115 @@ +using System.Collections.Concurrent; +using CursorLang.Core.Services; +using CursorLang.Tests.Shared; + +namespace CursorLang.Core.Tests.Services; + +/// +/// The one thing the agent says to the settings window: quit with me. +/// +/// +/// The event names here are the tests' own. The application's name is fixed, and a test +/// listening on it would answer for a settings window someone is using — or, signalling, +/// close it. +/// +public sealed class SettingsCloseSignalTests +{ + [Fact] + public void The_request_reaches_the_settings_window() + { + string suffix = UniqueSuffix(); + var signal = new SettingsCloseSignal(suffix); + + ConcurrentQueue requests = new(); + signal.CloseRequested += (_, e) => requests.Enqueue(e); + + try + { + signal.Listen(); + + Assert.True(RequestApart(suffix), "the request found nobody listening"); + Pump.WaitFor(() => !requests.IsEmpty, "the settings window got the request to close"); + } + finally + { + signal.Dispose(); + } + } + + // The usual case: the user quits from the tray with no settings window on the screen + [Fact] + public void A_request_with_no_settings_window_open_passes_without_consequence() + { + Assert.False(RequestApart(UniqueSuffix())); + } + + // The window has closed on its own, and the process is on its way out anyway + [Fact] + public void No_request_arrives_after_the_window_is_gone() + { + string suffix = UniqueSuffix(); + var signal = new SettingsCloseSignal(suffix); + + ConcurrentQueue requests = new(); + signal.CloseRequested += (_, e) => requests.Enqueue(e); + + signal.Listen(); + signal.Dispose(); + + RequestApart(suffix); + Pump.Pause(TimeSpan.FromMilliseconds(80)); + + Assert.Empty(requests); + } + + [Fact] + public void Listening_twice_leaves_one_listener() + { + string suffix = UniqueSuffix(); + var signal = new SettingsCloseSignal(suffix); + + var requests = 0; + signal.CloseRequested += (_, _) => Interlocked.Increment(ref requests); + + try + { + signal.Listen(); + signal.Listen(); + + Assert.True(RequestApart(suffix)); + Pump.WaitFor(() => Volatile.Read(ref requests) > 0, "the request arrived"); + Pump.Pause(TimeSpan.FromMilliseconds(80)); + + Assert.Equal(1, Volatile.Read(ref requests)); + } + finally + { + signal.Dispose(); + } + } + + [Fact] + public void Closing_without_listening_passes_without_consequence() + { + var signal = new SettingsCloseSignal(UniqueSuffix()); + + signal.Dispose(); + signal.Dispose(); + } + + // Every test gets a namespace of kernel objects of its own + private static string UniqueSuffix() => "." + Guid.NewGuid().ToString("N"); + + /// + /// Asks for the close the way the agent does it — from another process, and here + /// from another thread, which is as foreign as a test can get. + /// + private static bool RequestApart(string suffix) + { + var heard = false; + + Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix)); + + return heard; + } +} diff --git a/CursorLang.Core/Services/SettingsCloseSignal.cs b/CursorLang.Core/Services/SettingsCloseSignal.cs new file mode 100644 index 0000000..5794a93 --- /dev/null +++ b/CursorLang.Core/Services/SettingsCloseSignal.cs @@ -0,0 +1,98 @@ +namespace CursorLang.Core.Services; + +/// +/// Tells the settings window that the agent is quitting and it is to close with it. +/// +/// +/// The one thing the agent says to the settings window, and the mirror image of +/// . "Exit" in the tray menu means the application is done +/// with, and a settings window left alone on the screen after it is a window belonging +/// to nothing: the tray icon it was opened from is gone, and closing it would be the +/// user's only remaining move. +/// +/// A named event rather than a window message, because the agent has no handle to send +/// one to: the settings window lives in a process the agent starts and deliberately +/// does not keep hold of. The name has no Global prefix, so it lives in the +/// session namespace — same reasoning as , and the same +/// consequence: with fast user switching each user's halves talk to their own. +/// +/// Only the settings window creates the object; the agent opens what is already there +/// and stays silent when there is nothing. Were it the other way round, the request +/// would sit in an auto-reset event waiting for the next settings window to open and +/// close it the moment it did. +/// +internal sealed class SettingsCloseSignal : IDisposable +{ + private const string EventName = "CursorLang.CloseSettings"; + + private readonly string _eventName; + + private EventWaitHandle? _request; + private RegisteredWaitHandle? _wait; + + /// + /// Listens on the name the two halves agree on. + /// + /// + /// A namespace of its own. Empty for the application; the tests pass one so that + /// they do not answer for — or worse, close — a settings window someone is using. + /// + internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix; + + /// The agent asks for the window to be closed. + /// + /// Raised on a thread pool thread, wherever the wait happened to be answered — a + /// window obeys only its own, so the handler has to get back to it. + /// + internal event EventHandler? CloseRequested; + + /// + /// Asks the settings window of this session, if one is open, to close. Silence is + /// a normal answer: most of the time the user quits with no window on the screen. + /// + /// Whether there was anybody to hear it. + internal static bool RequestClose(string nameSuffix = "") + { + if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request)) + { + return false; + } + + using (request) + { + return request.Set(); + } + } + + /// Starts waiting for the request. Called once, by the settings window. + internal void Listen() + { + if (_request is not null) + { + return; + } + + _request = new EventWaitHandle(false, EventResetMode.AutoReset, _eventName); + + // As in the gate: the thread pool holds the wait, there is no reason to keep a + // thread of our own for a request that may never come + _wait = ThreadPool.RegisterWaitForSingleObject( + _request, + OnCloseSignalled, + state: null, + Timeout.Infinite, + executeOnlyOnce: false); + } + + public void Dispose() + { + _wait?.Unregister(null); + _wait = null; + + _request?.Dispose(); + _request = null; + } + + private void OnCloseSignalled(object? state, bool timedOut) => + CloseRequested?.Invoke(this, EventArgs.Empty); +} diff --git a/CursorLang.Settings/App.xaml.cs b/CursorLang.Settings/App.xaml.cs index 9174ee3..8e5c3f8 100644 --- a/CursorLang.Settings/App.xaml.cs +++ b/CursorLang.Settings/App.xaml.cs @@ -26,6 +26,7 @@ public partial class App : Application { private ServiceProvider? _services; private SingleInstanceGate? _instanceGate; + private SettingsCloseSignal? _closeSignal; protected override void OnStartup(StartupEventArgs e) { @@ -42,6 +43,10 @@ public partial class App : Application _instanceGate = gate; _instanceGate.ActivationRequested += OnActivationRequested; + _closeSignal = new SettingsCloseSignal(); + _closeSignal.CloseRequested += OnCloseRequested; + _closeSignal.Listen(); + var services = new ServiceCollection(); ConfigureServices(services); _services = services.BuildServiceProvider(); @@ -61,6 +66,12 @@ public partial class App : Application { _services?.Dispose(); + if (_closeSignal is not null) + { + _closeSignal.CloseRequested -= OnCloseRequested; + _closeSignal.Dispose(); + } + if (_instanceGate is not null) { _instanceGate.ActivationRequested -= OnActivationRequested; @@ -95,6 +106,27 @@ public partial class App : Application private void OnActivationRequested(object? sender, EventArgs e) => Dispatcher.BeginInvoke(ShowMainWindow); + // The agent is quitting. Answered on a thread pool thread, and the window is closed + // on its own one + private void OnCloseRequested(object? sender, EventArgs e) => + Dispatcher.BeginInvoke(CloseMainWindow); + + /// + /// Closes the window the way the title bar button does, so that everything hanging + /// off closing happens; ends the process + /// after it. Without a window there is nothing to close and the process simply ends. + /// + private void CloseMainWindow() + { + if (MainWindow is { } window) + { + window.Close(); + return; + } + + Shutdown(); + } + private void ShowMainWindow() { if (MainWindow is not { } window) diff --git a/README.RU.md b/README.RU.md index 33765e5..7db9213 100644 --- a/README.RU.md +++ b/README.RU.md @@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и в фоновый процесс. -Связь между ними — только `settings.json`. Окно пишет его целиком, во временный -файл, который одним движением встаёт на место, и посылает агенту -зарегистрированное оконное сообщение, по которому тот перечитывает файл. +Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его +целиком, во временный файл, который одним движением встаёт на место, и посылает +агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл. Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли единственного источника правды. Работающий агент необязателен — без него окно работает так же. За файлом никто не следит: писатель у него один, и он сам сообщает о записи. +В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню +трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на +экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт +именованным событием сессии, а не оконным сообщением: агент запускает этот процесс +и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это +нормальный ответ. + ## Трей Окно настроек — гость на экране, а не само приложение: оно показывается после установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в заголовке окна означают ровно то, что написано: окно закрывается, а его процесс -завершается. Выход из самого приложения — пункт «Выход» в меню трея. +завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое +окно настроек он закрывает вместе с агентом. Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку, выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF diff --git a/README.md b/README.md index 9ad357d..d71e85c 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,26 @@ the next time the window is asked for. the layout tracking. No UI, and it must stay that way — whatever lands there lands in the background process. -The connection between the two is `settings.json` and nothing else. The window -writes it — whole, into a temporary file moved into place in one step — and then +Everything the window has to say to the agent goes through `settings.json` and +nothing else. The window writes it — whole, into a temporary file moved into place in one step — and then posts a registered window message to the agent, which re-reads. The message carries no data: sending the changed values along would make the file stop being the only source of truth. An agent that is not running is a normal case — the window works the same. Nobody watches the file: it has one writer, and that writer speaks up. +The one word in the other direction is the last one: Exit in the tray menu asks an +open settings window to close before the agent goes, so that no window is left +belonging to nothing. It travels as a named event of the session rather than a +window message — the agent starts that process and deliberately keeps no handle to +it. Nobody listening is the usual case, and it is a normal answer. + ## The tray The settings window is a guest on the screen rather than the app itself: it shows up after the installation and whenever the icon or its menu is asked for. Both buttons in its title bar mean what they say — the window closes and its process -ends. The way out of the app itself is the Exit item of the tray menu. +ends. The way out of the app itself is the Exit item of the tray menu — and it +closes an open settings window along with the agent. The menu of the icon is a system one, drawn by Windows. Its captions still follow the language chosen in the settings, but the theme no longer reaches it: a WPF