diff --git a/CLAUDE.md b/CLAUDE.md
index 465ac50..ce5935e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -54,6 +54,7 @@ menu model and its own platform layer, and the library brings everything else. T
- `TrayApp/Tray/TrayApplication.cs` - The Avalonia host: projects the menu model onto native menu items
and owns `HasStarted`
- `TrayApp/ConsoleRunner.cs` - The headless mode, including Ctrl+C and `SIGTERM`
+- `TrayApp/Tray/ShutdownSignals.cs` - The tray mode's Ctrl+C and `SIGTERM`, turned into the Quit item's shutdown
- `TrayApp/TrayIconSet.cs` - Loads the tray PNGs by embedded resource name, and says what the assembly
does have when a name misses
- `TrayApp/DesktopSession.cs` - Whether this process has a desktop session to put a tray icon in
@@ -68,6 +69,8 @@ menu model and its own platform layer, and the library brings everything else. T
without a panel
- `scripts/click-tray-menu.py` - Clicks an entry in a running tool's tray menu over D-Bus, and prints the
menu either side of it
+- `scripts/check-tray-signals.sh` - Checks that `SIGTERM` and `SIGINT` end a tray run through `OnStop`
+ and the preference flush
### Dependencies
@@ -169,6 +172,19 @@ checked state flips, the disabled status line re-reads itself from the tool's ge
persisted toggle - the preference file lands under `~/.config`. Assertions on `TrayMenu` cover the model;
this is what covers the projection onto native menu items.
+`scripts/check-tray-signals.sh` covers the way out that does not go through the menu. It runs the demo in
+the tray, clicks the remembered toggle, and sends `SIGTERM`, then `SIGINT`, inside the preference store's
+500 ms debounce window:
+
+```bash
+dotnet build examples/TrayApp.Demo
+dbus-run-session -- scripts/check-tray-signals.sh
+```
+
+Each run has to exit 0 and leave the toggle saved. An exit of 143 or 130 means the signal killed the
+process outright, so the tool's `OnStop` never ran and the pending preference write was lost. Avalonia
+handles neither signal, which is why `RunTray` holds a `ShutdownSignals` for the length of the run.
+
## Packaging
`TrayApp/msbuild/ktsu.TrayApp.props` and `TrayApp/msbuild/ktsu.TrayApp.targets` are packed to `build/`
diff --git a/TrayApp.Test/ShutdownSignalsTests.cs b/TrayApp.Test/ShutdownSignalsTests.cs
new file mode 100644
index 0000000..61c1108
--- /dev/null
+++ b/TrayApp.Test/ShutdownSignalsTests.cs
@@ -0,0 +1,73 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.TrayApp.Test;
+
+using System.Runtime.InteropServices;
+using ktsu.TrayApp;
+using ktsu.TrayApp.Menu;
+using ktsu.TrayApp.Tray;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Tests for how a tray run answers Ctrl+C and SIGTERM.
+///
+///
+/// The signals themselves are not sent: the handlers are called directly, so the tests show what each one
+/// decides without a display or a process to kill. scripts/check-tray-signals.sh covers the real thing.
+///
+[TestClass]
+[DoNotParallelize]
+public class ShutdownSignalsTests
+{
+ private static TrayApplication Host()
+ {
+ TrayAppDefinition app = TrayAppBuilder.Create("demo").Toggle("One", () => true, _ => { }).Build();
+ return new TrayApplication(app, new TrayMenu(app.DisplayName, app.Status, app.Items), duration: null, onToggled: null);
+ }
+
+ [TestMethod]
+ public void TerminationBeforeTheTrayExists_IsLeftToEndTheProcess()
+ {
+ using ShutdownSignals signals = new(() => null);
+ PosixSignalContext context = new(PosixSignal.SIGTERM);
+
+ signals.OnTermination(context);
+
+ Assert.IsFalse(context.Cancel);
+ }
+
+ [TestMethod]
+ public void TerminationOnceTheTrayExists_IsTakenAndShutsTheTrayDown()
+ {
+ using TrayApplication host = Host();
+ using ShutdownSignals signals = new(() => host);
+ PosixSignalContext context = new(PosixSignal.SIGTERM);
+
+ signals.OnTermination(context);
+
+ Assert.IsTrue(context.Cancel);
+
+ // The signal already asked, so nothing is left for a second request to post.
+ Assert.IsFalse(host.RequestShutdown());
+ }
+
+ [TestMethod]
+ public void ARepeatedSignal_IsStillTakenButPostsNoSecondShutdown()
+ {
+ using TrayApplication host = Host();
+ using ShutdownSignals signals = new(() => host);
+
+ Assert.IsTrue(signals.RequestShutdown());
+ Assert.IsTrue(signals.RequestShutdown());
+ Assert.IsFalse(host.RequestShutdown());
+ }
+
+ [TestMethod]
+ public void RequestShutdown_PostsOnlyOnce()
+ {
+ using TrayApplication host = Host();
+
+ Assert.IsTrue(host.RequestShutdown());
+ Assert.IsFalse(host.RequestShutdown());
+ }
+}
diff --git a/TrayApp/Tray/ShutdownSignals.cs b/TrayApp/Tray/ShutdownSignals.cs
new file mode 100644
index 0000000..7eefef0
--- /dev/null
+++ b/TrayApp/Tray/ShutdownSignals.cs
@@ -0,0 +1,70 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.TrayApp.Tray;
+
+using System;
+using System.Runtime.InteropServices;
+
+///
+/// Turns Ctrl+C and SIGTERM into the shutdown the tray's Quit item asks for, for as long as it is held.
+///
+///
+/// Avalonia handles neither signal, so without this a kill, a systemctl stop or a logout ended a
+/// tray run through the runtime's default handler: the tool's stop action never ran, and a toggle clicked inside
+/// the preference store's debounce window was never saved. Before the tray exists there is nothing to shut
+/// down, so the signal is left to end the process as it would have.
+///
+internal sealed class ShutdownSignals : IDisposable
+{
+ private readonly Func host;
+ private readonly PosixSignalRegistration termination;
+
+ ///
+ /// Initializes a new instance of the class, taking both signals.
+ ///
+ /// Reads the tray host, which does not exist until Avalonia has called back for it.
+ internal ShutdownSignals(Func host)
+ {
+ Ensure.NotNull(host);
+ this.host = host;
+
+ Console.CancelKeyPress += OnCancelKeyPress;
+
+ // SIGTERM does not go through CancelKeyPress.
+ termination = PosixSignalRegistration.Create(PosixSignal.SIGTERM, OnTermination);
+ }
+
+ ///
+ /// Releases both signals back to their default handling.
+ ///
+ public void Dispose()
+ {
+ Console.CancelKeyPress -= OnCancelKeyPress;
+ termination.Dispose();
+ }
+
+ ///
+ /// Handles SIGTERM, keeping the process alive only when there is a tray to shut down instead.
+ ///
+ /// The signal being delivered.
+ internal void OnTermination(PosixSignalContext context) => context.Cancel = RequestShutdown();
+
+ ///
+ /// Asks the tray to shut down, if there is one yet.
+ ///
+ ///
+ /// when a tray took the request, so the signal's default handling should be cancelled.
+ ///
+ internal bool RequestShutdown()
+ {
+ if (host() is not TrayApplication running)
+ {
+ return false;
+ }
+
+ _ = running.RequestShutdown();
+ return true;
+ }
+
+ private void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) => eventArgs.Cancel = RequestShutdown();
+}
diff --git a/TrayApp/Tray/TrayApplication.cs b/TrayApp/Tray/TrayApplication.cs
index f40f6ee..ef7ce5c 100644
--- a/TrayApp/Tray/TrayApplication.cs
+++ b/TrayApp/Tray/TrayApplication.cs
@@ -4,6 +4,7 @@ namespace ktsu.TrayApp.Tray;
using System;
using System.Collections.Generic;
+using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -40,6 +41,7 @@ internal sealed class TrayApplication : Application, IDisposable
private TrayIcon? trayIcon;
private DispatcherTimer? expiryTimer;
private bool disposed;
+ private int shutdownRequested;
///
/// Initializes a new instance of the class.
@@ -239,6 +241,28 @@ internal void OnTrayIconClicked(object? sender, EventArgs e)
private void OnQuitRequested(object? sender, EventArgs e) => Shutdown();
+ ///
+ /// Ends the run the way the Quit item does, from any thread.
+ ///
+ ///
+ /// This is what a signal handler calls, so it runs on whichever thread delivered the signal. The shutdown
+ /// itself is posted to the UI thread, where the lifetime expects it, and from there the run ends through
+ /// and , so the tool's stop action runs and the caller gets to
+ /// flush its preferences. Only the first request posts anything: Ctrl+C pressed twice, or a signal
+ /// arriving while the user is quitting, must not shut down a lifetime that is already shutting down.
+ ///
+ /// when this call posted the shutdown, when an earlier one had.
+ internal bool RequestShutdown()
+ {
+ if (Interlocked.Exchange(ref shutdownRequested, 1) != 0)
+ {
+ return false;
+ }
+
+ Dispatcher.UIThread.Post(Shutdown);
+ return true;
+ }
+
private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) => Dispose();
///
diff --git a/TrayApp/TrayAppBuilder.cs b/TrayApp/TrayAppBuilder.cs
index 43e77a0..73cf5ca 100644
--- a/TrayApp/TrayAppBuilder.cs
+++ b/TrayApp/TrayAppBuilder.cs
@@ -595,11 +595,15 @@ void Remember(TrayToggleItem toggle)
try
{
- // StartWithClassicDesktopLifetime blocks until the tray quits.
- return AppBuilder.Configure(() => host = new TrayApplication(app, menu, options.Duration, Remember))
- .UsePlatformDetect()
- .With(new MacOSPlatformOptions { ShowInDock = false })
- .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown);
+ // Released before any fallback to the console, which takes the same signals itself.
+ using (new ShutdownSignals(() => host))
+ {
+ // StartWithClassicDesktopLifetime blocks until the tray quits.
+ return AppBuilder.Configure(() => host = new TrayApplication(app, menu, options.Duration, Remember))
+ .UsePlatformDetect()
+ .With(new MacOSPlatformOptions { ShowInDock = false })
+ .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown);
+ }
}
catch (Exception ex)
{
diff --git a/scripts/check-tray-signals.sh b/scripts/check-tray-signals.sh
new file mode 100755
index 0000000..a8f4d02
--- /dev/null
+++ b/scripts/check-tray-signals.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# Copyright (c) 2023-2026 ktsu-dev contributors
+#
+# Checks that a tray run ends through the tool's own shutdown when it is sent SIGTERM or SIGINT: the
+# process exits 0 rather than dying to the signal, and a toggle clicked inside the preference store's
+# 500 ms debounce window is still saved. Needs Xvfb, dbus-run-session and `pip install dbus-next`.
+#
+# dotnet build examples/TrayApp.Demo
+# dbus-run-session -- scripts/check-tray-signals.sh
+#
+# Exits non-zero, naming the signal, on the first run that does not shut down cleanly.
+
+set -u
+
+root="$(cd "$(dirname "$0")/.." && pwd)"
+demo="$root/examples/TrayApp.Demo/bin/Debug/net10.0/ktsu.TrayApp.Demo.dll"
+work="$(mktemp -d)"
+trap 'kill $(jobs -p) 2>/dev/null; rm -rf "$work"' EXIT
+
+if [ ! -f "$demo" ]; then
+ echo "Build the demo first: dotnet build examples/TrayApp.Demo" >&2
+ exit 2
+fi
+
+check() {
+ local signal="$1"
+ local home="$work/$signal"
+ mkdir -p "$home"
+
+ python3 -u "$root/scripts/status-notifier-watcher.py" > "$home/watcher.log" 2>&1 &
+ local watcher=$!
+
+ # A non-interactive shell starts background jobs with SIGINT ignored, and an ignored signal stays
+ # ignored across exec, so it is put back to the default for the run to receive it at all.
+ HOME="$home" XDG_CONFIG_HOME="$home/.config" xvfb-run -a \
+ env --default-signal=INT DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" \
+ dotnet "$demo" --tray > "$home/demo.log" 2>&1 &
+ local run=$!
+
+ local item=""
+ for _ in $(seq 1 60); do
+ item="$(grep -o 'org.kde.StatusNotifierItem-[0-9-]*' "$home/watcher.log" | head -1)"
+ [ -n "$item" ] && break
+ sleep 0.5
+ done
+
+ if [ -z "$item" ]; then
+ echo "SIG$signal: the tray icon never registered" >&2
+ cat "$home/demo.log" >&2
+ return 1
+ fi
+
+ local pid
+ pid="$(pgrep -f "^dotnet $demo" | head -1)"
+
+ # Flip the remembered toggle, then send the signal well inside the debounce window.
+ python3 -u "$root/scripts/click-tray-menu.py" "$item" Running > "$home/click.log" 2>&1 &
+ for _ in $(seq 1 100); do
+ grep -q '^clicking' "$home/click.log" && break
+ sleep 0.02
+ done
+ sleep 0.1
+ kill "-$signal" "$pid"
+
+ wait "$run"
+ local status=$?
+ kill "$watcher" 2>/dev/null
+
+ local saved
+ saved="$(cat "$home/.config/trayapp-demo/tray.json" 2>/dev/null)"
+
+ if [ "$status" -ne 0 ]; then
+ echo "SIG$signal: exited $status instead of shutting down" >&2
+ return 1
+ fi
+
+ if ! grep -q '"running": false' <<< "$saved"; then
+ echo "SIG$signal: the toggle clicked before the signal was not saved: ${saved:-no preference file}" >&2
+ return 1
+ fi
+
+ echo "SIG$signal: shut down cleanly and saved the toggle"
+}
+
+check TERM && check INT