added sending closing event message to settings windows #7
@@ -133,6 +133,34 @@ public sealed partial class EndToEndTests
|
|||||||
Assert.False(launch.Process.HasExited);
|
Assert.False(launch.Process.HasExited);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The menu itself is out of reach of a test — it is a <c>TrackPopupMenuEx</c> 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.
|
||||||
|
/// </remarks>
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A started application that shuts down together with the check.</summary>
|
/// <summary>A started application that shuts down together with the check.</summary>
|
||||||
private sealed class Launch : IDisposable
|
private sealed class Launch : IDisposable
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -125,5 +125,12 @@ internal sealed class Agent : IDisposable
|
|||||||
|
|
||||||
private void OnOpenRequested(object? sender, EventArgs e) => SettingsLauncher.Open();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using CursorLang.Core.Services;
|
||||||
|
using CursorLang.Tests.Shared;
|
||||||
|
|
||||||
|
namespace CursorLang.Core.Tests.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one thing the agent says to the settings window: quit with me.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SettingsCloseSignalTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void The_request_reaches_the_settings_window()
|
||||||
|
{
|
||||||
|
string suffix = UniqueSuffix();
|
||||||
|
var signal = new SettingsCloseSignal(suffix);
|
||||||
|
|
||||||
|
ConcurrentQueue<EventArgs> 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<EventArgs> 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");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static bool RequestApart(string suffix)
|
||||||
|
{
|
||||||
|
var heard = false;
|
||||||
|
|
||||||
|
Pump.RunApart(() => heard = SettingsCloseSignal.RequestClose(suffix));
|
||||||
|
|
||||||
|
return heard;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
namespace CursorLang.Core.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells the settings window that the agent is quitting and it is to close with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one thing the agent says to the settings window, and the mirror image of
|
||||||
|
/// <see cref="SettingsSignal"/>. "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 <c>Global</c> prefix, so it lives in the
|
||||||
|
/// session namespace — same reasoning as <see cref="SingleInstanceGate"/>, 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.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SettingsCloseSignal : IDisposable
|
||||||
|
{
|
||||||
|
private const string EventName = "CursorLang.CloseSettings";
|
||||||
|
|
||||||
|
private readonly string _eventName;
|
||||||
|
|
||||||
|
private EventWaitHandle? _request;
|
||||||
|
private RegisteredWaitHandle? _wait;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Listens on the name the two halves agree on.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="nameSuffix">
|
||||||
|
/// 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.
|
||||||
|
/// </param>
|
||||||
|
internal SettingsCloseSignal(string nameSuffix = "") => _eventName = EventName + nameSuffix;
|
||||||
|
|
||||||
|
/// <summary>The agent asks for the window to be closed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 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.
|
||||||
|
/// </remarks>
|
||||||
|
internal event EventHandler? CloseRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether there was anybody to hear it.</returns>
|
||||||
|
internal static bool RequestClose(string nameSuffix = "")
|
||||||
|
{
|
||||||
|
if (!EventWaitHandle.TryOpenExisting(EventName + nameSuffix, out EventWaitHandle? request))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (request)
|
||||||
|
{
|
||||||
|
return request.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts waiting for the request. Called once, by the settings window.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
private ServiceProvider? _services;
|
private ServiceProvider? _services;
|
||||||
private SingleInstanceGate? _instanceGate;
|
private SingleInstanceGate? _instanceGate;
|
||||||
|
private SettingsCloseSignal? _closeSignal;
|
||||||
|
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,10 @@ public partial class App : Application
|
|||||||
_instanceGate = gate;
|
_instanceGate = gate;
|
||||||
_instanceGate.ActivationRequested += OnActivationRequested;
|
_instanceGate.ActivationRequested += OnActivationRequested;
|
||||||
|
|
||||||
|
_closeSignal = new SettingsCloseSignal();
|
||||||
|
_closeSignal.CloseRequested += OnCloseRequested;
|
||||||
|
_closeSignal.Listen();
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
ConfigureServices(services);
|
ConfigureServices(services);
|
||||||
_services = services.BuildServiceProvider();
|
_services = services.BuildServiceProvider();
|
||||||
@@ -61,6 +66,12 @@ public partial class App : Application
|
|||||||
{
|
{
|
||||||
_services?.Dispose();
|
_services?.Dispose();
|
||||||
|
|
||||||
|
if (_closeSignal is not null)
|
||||||
|
{
|
||||||
|
_closeSignal.CloseRequested -= OnCloseRequested;
|
||||||
|
_closeSignal.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
if (_instanceGate is not null)
|
if (_instanceGate is not null)
|
||||||
{
|
{
|
||||||
_instanceGate.ActivationRequested -= OnActivationRequested;
|
_instanceGate.ActivationRequested -= OnActivationRequested;
|
||||||
@@ -95,6 +106,27 @@ public partial class App : Application
|
|||||||
private void OnActivationRequested(object? sender, EventArgs e) =>
|
private void OnActivationRequested(object? sender, EventArgs e) =>
|
||||||
Dispatcher.BeginInvoke(ShowMainWindow);
|
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);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the window the way the title bar button does, so that everything hanging
|
||||||
|
/// off closing happens; <see cref="ShutdownMode.OnMainWindowClose"/> ends the process
|
||||||
|
/// after it. Without a window there is nothing to close and the process simply ends.
|
||||||
|
/// </summary>
|
||||||
|
private void CloseMainWindow()
|
||||||
|
{
|
||||||
|
if (MainWindow is { } window)
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
private void ShowMainWindow()
|
private void ShowMainWindow()
|
||||||
{
|
{
|
||||||
if (MainWindow is not { } window)
|
if (MainWindow is not { } window)
|
||||||
|
|||||||
+12
-4
@@ -84,20 +84,28 @@ MSIX всегда выполняются в контексте вошедшег
|
|||||||
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
раскладкой. Без UI, и так должно остаться: всё, что попадёт туда, попадёт и
|
||||||
в фоновый процесс.
|
в фоновый процесс.
|
||||||
|
|
||||||
Связь между ними — только `settings.json`. Окно пишет его целиком, во временный
|
Всё, что окно сообщает агенту, идёт только через `settings.json`. Окно пишет его
|
||||||
файл, который одним движением встаёт на место, и посылает агенту
|
целиком, во временный файл, который одним движением встаёт на место, и посылает
|
||||||
зарегистрированное оконное сообщение, по которому тот перечитывает файл.
|
агенту зарегистрированное оконное сообщение, по которому тот перечитывает файл.
|
||||||
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
|
Сообщение не несёт данных: пересылка самих изменений лишила бы файл роли
|
||||||
единственного источника правды. Работающий агент необязателен — без него окно
|
единственного источника правды. Работающий агент необязателен — без него окно
|
||||||
работает так же. За файлом никто не следит: писатель у него один, и он сам
|
работает так же. За файлом никто не следит: писатель у него один, и он сам
|
||||||
сообщает о записи.
|
сообщает о записи.
|
||||||
|
|
||||||
|
В обратную сторону агент говорит одно слово, и то последнее: пункт «Выход» в меню
|
||||||
|
трея просит открытое окно настроек закрыться, прежде чем агент уйдёт, — чтобы на
|
||||||
|
экране не осталось окна, которому больше ничего не принадлежит. Просьба идёт
|
||||||
|
именованным событием сессии, а не оконным сообщением: агент запускает этот процесс
|
||||||
|
и намеренно не держит на него ссылки. Чаще всего слушать её некому, и это
|
||||||
|
нормальный ответ.
|
||||||
|
|
||||||
## Трей
|
## Трей
|
||||||
|
|
||||||
Окно настроек — гость на экране, а не само приложение: оно показывается после
|
Окно настроек — гость на экране, а не само приложение: оно показывается после
|
||||||
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
|
установки и всякий раз, когда его просят иконка или её меню. Обе кнопки в
|
||||||
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
|
заголовке окна означают ровно то, что написано: окно закрывается, а его процесс
|
||||||
завершается. Выход из самого приложения — пункт «Выход» в меню трея.
|
завершается. Выход из самого приложения — пункт «Выход» в меню трея, и открытое
|
||||||
|
окно настроек он закрывает вместе с агентом.
|
||||||
|
|
||||||
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
|
Меню иконки системное, его рисует Windows. Надписи по-прежнему следуют языку,
|
||||||
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
|
выбранному в настройках, а тема до меню больше не дотягивается: меню на WPF
|
||||||
|
|||||||
@@ -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
|
the layout tracking. No UI, and it must stay that way — whatever lands there lands
|
||||||
in the background process.
|
in the background process.
|
||||||
|
|
||||||
The connection between the two is `settings.json` and nothing else. The window
|
Everything the window has to say to the agent goes through `settings.json` and
|
||||||
writes it — whole, into a temporary file moved into place in one step — and then
|
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
|
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
|
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
|
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.
|
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 tray
|
||||||
|
|
||||||
The settings window is a guest on the screen rather than the app itself: it shows
|
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
|
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
|
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 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
|
the language chosen in the settings, but the theme no longer reaches it: a WPF
|
||||||
|
|||||||
Reference in New Issue
Block a user