From a7805e9e517ff9b579bf0b7d1f3c8f3842b600e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 04:07:55 +0000 Subject: [PATCH] Adopt ktsu.TrayApp and delete what it now owns The tray host, the tray-versus-console decision and its fallback, Ctrl+C and SIGTERM, the --for timer, the standard flags, the --status report, the remembered tray state, and the MSBuild props that trim the tool payload were all extracted into ktsu.TrayApp. NoSleep consumes the package and deletes its copies: Cli/, Tray/, ConsoleRunner, DesktopSession, StatusReport, TrayPreferences, and the _TrimToolRuntimeAssets target. The NoSleep library - ISleepBlocker, KeepAwakeController, the platform blockers - is untouched. What is left in the tool is the menu, the three flags NoSleep adds, the --status rows only NoSleep can answer, and the wiring between them and KeepAwakeController. Program keeps the user's keep-awake intent in a local bool rather than calling Enable() from the toggle's setter, because a run restores preferences first, then applies the command line, and only then runs OnStart: a setter that enabled directly would have OnStart override an explicit --off, or a remembered "off", a moment later. Persistence moves from ktsu.AppDataStorage to ktsu.Essentials over the ConfigHome/Json/Native providers, assembled here because the library takes IPersistenceProvider and nothing else. Remembered state now lives in nosleep/tray.json under the config home, so a previous install's state is not carried over. Three behaviour changes come with the move, none of them papered over: - nosleep --status now exits 0 on a machine with no inhibitor, where it used to exit 1. The library has no hook for the exit code. - The tray's status line no longer mentions the --for window: the library parses the duration and does not pass it to the status callback. - --off now applies in the console mode too, where it used to be ignored, because a flag handler cannot see which mode the run resolved to. Also: the debug environment variable is TRAYAPP_DEBUG rather than NOSLEEP_DEBUG, the generated usage block has no Examples section, and the console mode's own wording comes from the library. Verified: clean build with no warnings, 27 tests passing through the test host, the tray registering a StatusNotifierItem under Xvfb and D-Bus with a menu click flipping its state, the no-DISPLAY fallback, and a packed payload of 50,209,047 bytes against 190,046,536 with the package's trimming disabled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UfEY8ztrV4M9EuzX65T8QU --- .github/copilot-instructions.md | 17 +- CLAUDE.md | 69 +++--- Directory.Packages.props | 9 +- NoSleep.Test/CommandLineParserTests.cs | 117 ---------- NoSleep.Test/DurationParserTests.cs | 62 ----- NoSleep.Test/PlatformIntegrationTests.cs | 41 +--- NoSleep.Tool/Cli/CommandLineOptions.cs | 67 ------ NoSleep.Tool/Cli/CommandLineParser.cs | 147 ------------ NoSleep.Tool/Cli/DurationParser.cs | 104 --------- NoSleep.Tool/Cli/HelpText.cs | 69 ------ NoSleep.Tool/ConsoleRunner.cs | 98 -------- NoSleep.Tool/DesktopSession.cs | 43 ---- NoSleep.Tool/NoSleep.Tool.csproj | 65 ++---- NoSleep.Tool/Program.cs | 226 +++++++++--------- NoSleep.Tool/StatusReport.cs | 55 ----- NoSleep.Tool/Tray/TrayApplication.cs | 280 ----------------------- NoSleep.Tool/Tray/TrayIconAssets.cs | 53 ----- NoSleep.Tool/TrayPreferences.cs | 25 -- README.md | 20 +- 19 files changed, 197 insertions(+), 1370 deletions(-) delete mode 100644 NoSleep.Test/CommandLineParserTests.cs delete mode 100644 NoSleep.Test/DurationParserTests.cs delete mode 100644 NoSleep.Tool/Cli/CommandLineOptions.cs delete mode 100644 NoSleep.Tool/Cli/CommandLineParser.cs delete mode 100644 NoSleep.Tool/Cli/DurationParser.cs delete mode 100644 NoSleep.Tool/Cli/HelpText.cs delete mode 100644 NoSleep.Tool/ConsoleRunner.cs delete mode 100644 NoSleep.Tool/DesktopSession.cs delete mode 100644 NoSleep.Tool/StatusReport.cs delete mode 100644 NoSleep.Tool/Tray/TrayApplication.cs delete mode 100644 NoSleep.Tool/Tray/TrayIconAssets.cs delete mode 100644 NoSleep.Tool/TrayPreferences.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f64cb8f..90d8619 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,7 +10,7 @@ and falls back to a headless console mode on machines with no desktop session. - Native inhibitors on every platform, not input faking: `SetThreadExecutionState`, IOKit power assertions, and logind or GNOME session inhibitors -- One tray icon and menu across `Shell_NotifyIcon`, `NSStatusItem`, and StatusNotifierItem, via Avalonia +- One tray icon and menu across `Shell_NotifyIcon`, `NSStatusItem`, and StatusNotifierItem, via `ktsu.TrayApp` - Separate control over system sleep and display sleep - Headless mode with Ctrl+C and `SIGTERM` handling, and an optional duration after which NoSleep exits - `nosleep --status` reports what the current machine actually supports @@ -31,10 +31,8 @@ NoSleep/ ├── NoSleep/ # Core library (ktsu.NoSleep), no UI dependencies │ ├── Contracts/ # ISleepBlocker │ └── Platforms/ # Per-platform inhibitors and their helpers -├── NoSleep.Tool/ # The nosleep command (ktsu.NoSleep.Tool) -│ ├── Assets/ # Generated tray icons, embedded as resources -│ ├── Cli/ # Argument parsing, duration parsing, usage text -│ └── Tray/ # Avalonia tray application +├── NoSleep.Tool/ # The nosleep command (ktsu.NoSleep.Tool): one Program.cs +│ └── Assets/ # Generated tray icons, embedded as resources ├── NoSleep.Test/ # MSTest suite └── scripts/ # Icon generation ``` @@ -55,7 +53,8 @@ NoSleep/ that set it - `ChildProcessHold` must keep the helper's standard input pipe open, so a killed NoSleep releases its Linux inhibitor rather than orphaning it -- `TrayApplication.HasStarted` distinguishes an Avalonia teardown exception from a tray that never came up; - removing it makes a clean quit restart in the console with the machine still awake -- `_TrimToolRuntimeAssets` in `NoSleep.Tool.csproj` keeps the tool package to 50 MB rather than 190 MB; - removing it ships SkiaSharp natives and debug symbols for every RID +- `Program` holds the user's keep-awake intent in a local `bool` and only acts on it in `OnStart`: a + toggle setter that enabled directly would be overridden by `OnStart` a moment later, so `--off` and a + remembered "off" would both stop working +- The tool package stays at 50 MB rather than 190 MB because `ktsu.TrayApp`'s `build/` props trim the + per-RID SkiaSharp natives and their debug symbols; `TrayAppTrimToolRuntimeAssets=false` turns that off diff --git a/CLAUDE.md b/CLAUDE.md index 72903ca..6daadff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,28 +54,27 @@ This is a .NET tool (`ktsu.NoSleep.Tool`, installed as the `nosleep` command) wi - `NoSleep/Platforms/LinuxSleepBlocker.cs` - `systemd-inhibit` and `gnome-session-inhibit` as child processes - `NoSleep/Platforms/ChildProcessHold.cs` - The helper-process lifetime used by the Linux blocker - `NoSleep/Platforms/ExecutableLocator.cs` - `PATH` lookup, so support can be reported without a process launch -- `NoSleep.Tool/Program.cs` - Argument dispatch and the tray/console decision -- `NoSleep.Tool/Cli/` - Hand-written argument parsing, duration parsing, and the usage text -- `NoSleep.Tool/Tray/TrayApplication.cs` - The Avalonia tray icon and its native menu -- `NoSleep.Tool/Tray/TrayIconAssets.cs` - Loads the two embedded tray PNGs -- `NoSleep.Tool/ConsoleRunner.cs` - The headless mode, including Ctrl+C and `SIGTERM` handling -- `NoSleep.Tool/StatusReport.cs` - The text behind `nosleep --status` +- `NoSleep.Tool/Program.cs` - The whole tool: the menu, the three flags NoSleep adds, the `--status` rows, + and the wiring between them and `KeepAwakeController`, described to `ktsu.TrayApp`'s `TrayAppBuilder` - `scripts/generate-icons.py` - Draws `NoSleep.Tool/Assets/*.png` and the repository's `icon.png` ### Dependencies -- **Avalonia / Avalonia.Desktop / Avalonia.Native** - The only cross-platform tray icon API that covers - `Shell_NotifyIcon`, `NSStatusItem`, and StatusNotifierItem from one codebase. `Avalonia.Native` is - referenced directly for `MacOSPlatformOptions`, which keeps the tray app out of the macOS dock -- **ktsu.AppDataStorage** - Persists the tray's remembered state +- **ktsu.TrayApp** - The tray host and everything around it: the Avalonia tray icon, the tray-versus-console + decision and its fallback, Ctrl+C and `SIGTERM`, the `--for` timer, the standard flags, the `--status` + report, debounced preference persistence, and the MSBuild props that trim the tool payload. All of it was + extracted from this repository; Avalonia arrives through it and is not referenced here +- **ktsu.Essentials + the ConfigHome / Json / Native providers** - The persistence stack behind the tray's + remembered state. `ktsu.TrayApp` takes `IPersistenceProvider` and nothing else, so where the + settings live (`nosleep/tray.json` under the config home) is this tool's choice, assembled in `Program` - **Polyfill** - Required by KTSU0001 for non-test projects; also supplies `Ensure.NotNull` ## Architecture The split is deliberate: `ISleepBlocker` implementations know how to take and drop one platform inhibitor -and nothing else, `KeepAwakeController` owns whether one *should* be held, and the tool's two front ends - -the tray and the console runner - only drive the controller. Nothing in `NoSleep` references Avalonia, so -the library is usable from a service or a test without a windowing stack. +and nothing else, `KeepAwakeController` owns whether one *should* be held, and the tool only describes a +menu over the controller to `ktsu.TrayApp`. Nothing in `NoSleep` references Avalonia, so the library is +usable from a service or a test without a windowing stack. ```csharp using KeepAwakeController controller = new(); @@ -91,22 +90,23 @@ Three platform details are load-bearing and easy to undo by accident: - **Linux**: inhibitor locks belong to a process, so `ChildProcessHold` keeps the helper's standard input pipe open. When NoSleep dies the pipe closes, the helper reaches end of input, and the lock goes with it - without that, a `kill -9` would orphan an inhibitor with nothing left to release it. -- **Linux tray**: Avalonia's `DBusTrayIconImpl.WatchAsync` is `async void` with an exception filter that - stops applying once the icon is disposed, so an ordinary shutdown can throw a `TaskCanceledException` out - of the run loop *after* the tray has done its job. `TrayApplication.HasStarted` is how `Program.RunTray` - tells that apart from a tray that never came up; without it, a clean quit would restart in the console and - keep the machine awake. +- **Linux tray**: Avalonia's teardown throws after a perfectly ordinary shutdown, and a tray that never came + up has to fall back to the console instead of killing the process. Both now belong to `ktsu.TrayApp`, + whose CLAUDE.md lists them under "Load-bearing behaviour" - the tray decision (`--no-tray` wins, then + `--tray`, then `DesktopSession.IsAvailable`) and the fallback are its rules now, not this repository's. -The tray decision in `Program.UseTray` is: `--no-tray` wins, then `--tray`, then `DesktopSession.IsAvailable`. -If the windowing stack still refuses, the run falls back to the console rather than exiting - a process -someone started to keep a machine awake should not die because a panel was missing. +One thing here is load-bearing and easy to undo by accident. `Program` keeps *whether the user wants to be +kept awake* in a local `bool` rather than calling `Enable()` from the toggle's setter, because a run applies +the remembered preferences first, then the command line, and only then `OnStart`. A setter that enabled +directly would have `OnStart` override an explicit `--off`, or a remembered "off", a moment later. ## Testing MSTest with Microsoft Testing Platform. `FakeSleepBlocker` stands in for the platform layer so `KeepAwakeControllerTests` can assert on acquire/release sequences anywhere. `PlatformIntegrationTests` checks the things that can only be asserted against the machine the tests are running on, including that the tray -PNGs are embedded under the resource names `TrayIconAssets` resolves by string. +PNGs are embedded under the resource names `TrayIconSet` resolves by string - the names are passed to +`ktsu.TrayApp` as strings, so a renamed asset is otherwise a run-time failure on a machine with a display. `ChildProcessHoldTests` uses `/bin/cat` and `/bin/sh -c "exit 1"` rather than `systemd-inhibit`, because a build agent has no logind bus to take a real lock on; those cases report inconclusive off Unix. The exiting @@ -114,21 +114,30 @@ helper is spelled as a shell invocation on purpose: POSIX puts `sh` at `/bin/sh` is `/bin/false` on Linux and `/usr/bin/false` on macOS, so hardcoding the Linux path failed on macOS for the wrong reason - the helper could not be started at all, rather than starting and exiting. -Verifying the Linux tray end to end needs a display and a status-notifier host on the session bus: +Verifying the Linux tray end to end needs a display and a status-notifier host on the session bus. +`ktsu.TrayApp` ships both halves of that rig in its `scripts/` directory: ```bash +pip install dbus-next eval "$(dbus-launch --sh-syntax)" -# ... start a StatusNotifierWatcher on the bus ... -xvfb-run -a dotnet run --project NoSleep.Tool -- --tray --for 5s +python3 scripts/status-notifier-watcher.py > watcher.log 2>&1 & +xvfb-run -a env DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" \ + dotnet run --project NoSleep.Tool -- --tray --for 5s +python3 scripts/click-tray-menu.py "$(grep -o 'org.kde.StatusNotifierItem-[0-9-]*' watcher.log | head -1)" "Keep awake" ``` +Success is the watcher logging a registered item, the run lasting the full window and exiting 0, and the +click flipping the entry's checked state with the status line re-reading itself. + ## Packaging -`NoSleep.Tool.csproj` trims the tool payload in `_TrimToolRuntimeAssets`. A `DotnetTool` package is -RID-agnostic, so without it SkiaSharp's native assets ship for every RID it supports, plus their debug -symbols - 190 MB against the current 50 MB. `NoSleepToolRuntimeIdentifiers` lists the runtimes that keep -their native assets; anything else falls back to the console mode, which needs none. Adding a RID back is a -one-line change there. +The tool payload is trimmed by `build/ktsu.TrayApp.props` and `.targets`, which arrive with the +`ktsu.TrayApp` package and are imported into any project referencing it. A `DotnetTool` package is +RID-agnostic, so without them SkiaSharp's native assets ship for every RID it supports, plus their debug +symbols: 190 MB against the current 50 MB, measured here with +`dotnet pack NoSleep.Tool/NoSleep.Tool.csproj -c Release -p:TrayAppTrimToolRuntimeAssets=false`. +`TrayAppToolRuntimeIdentifiers` lists the runtimes that keep their native assets; anything else falls back +to the console mode, which needs none. Adding a RID back is a one-line property in this project. ## CI/CD diff --git a/Directory.Packages.props b/Directory.Packages.props index 7a8f3ad..f0386f1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,10 +3,11 @@ true - - - - + + + + + diff --git a/NoSleep.Test/CommandLineParserTests.cs b/NoSleep.Test/CommandLineParserTests.cs deleted file mode 100644 index ba8a79d..0000000 --- a/NoSleep.Test/CommandLineParserTests.cs +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Test; - -using System; -using ktsu.NoSleep.Tool.Cli; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -/// -/// Tests for . -/// -[TestClass] -public class CommandLineParserTests -{ - [TestMethod] - public void TryParse_WithNoArguments_ReturnsDefaults() - { - Assert.IsTrue(CommandLineParser.TryParse([], out CommandLineOptions options, out string error)); - - Assert.AreEqual(string.Empty, error); - Assert.IsFalse(options.TrayRequested); - Assert.IsFalse(options.TraySuppressed); - Assert.IsFalse(options.KeepDisplayAwake); - Assert.IsFalse(options.StartDisabled); - Assert.IsNull(options.Duration); - Assert.AreEqual(SleepBlockRequest.DefaultReason, options.Reason); - } - - [TestMethod] - [DataRow("-t")] - [DataRow("--tray")] - public void TryParse_WithTray_SetsTrayRequested(string argument) - { - Assert.IsTrue(CommandLineParser.TryParse([argument], out CommandLineOptions options, out _)); - Assert.IsTrue(options.TrayRequested); - } - - [TestMethod] - [DataRow("-d")] - [DataRow("--display")] - public void TryParse_WithDisplay_SetsKeepDisplayAwake(string argument) - { - Assert.IsTrue(CommandLineParser.TryParse([argument], out CommandLineOptions options, out _)); - Assert.IsTrue(options.KeepDisplayAwake); - Assert.IsTrue(options.ToRequest().KeepDisplayAwake); - } - - [TestMethod] - public void TryParse_WithNoTray_SetsTraySuppressed() - { - Assert.IsTrue(CommandLineParser.TryParse(["--no-tray"], out CommandLineOptions options, out _)); - Assert.IsTrue(options.TraySuppressed); - } - - [TestMethod] - public void TryParse_WithDuration_ParsesIt() - { - Assert.IsTrue(CommandLineParser.TryParse(["--for", "90m"], out CommandLineOptions options, out _)); - Assert.AreEqual(TimeSpan.FromMinutes(90), options.Duration); - } - - [TestMethod] - public void TryParse_WithReason_CarriesItIntoTheRequest() - { - Assert.IsTrue(CommandLineParser.TryParse(["--reason", "long build"], out CommandLineOptions options, out _)); - Assert.AreEqual("long build", options.ToRequest().Reason); - } - - [TestMethod] - public void TryParse_WithCombinedSwitches_SetsAllOfThem() - { - Assert.IsTrue(CommandLineParser.TryParse(["--no-tray", "-d", "-f", "2h"], out CommandLineOptions options, out _)); - - Assert.IsTrue(options.TraySuppressed); - Assert.IsTrue(options.KeepDisplayAwake); - Assert.AreEqual(TimeSpan.FromHours(2), options.Duration); - } - - [TestMethod] - public void TryParse_WithTrayAndNoTray_Fails() - { - Assert.IsFalse(CommandLineParser.TryParse(["--tray", "--no-tray"], out _, out string error)); - StringAssert.Contains(error, "contradict", StringComparison.Ordinal); - } - - [TestMethod] - public void TryParse_WithAnUnknownOption_Fails() - { - Assert.IsFalse(CommandLineParser.TryParse(["--sleep-more"], out _, out string error)); - StringAssert.Contains(error, "--sleep-more", StringComparison.Ordinal); - } - - [TestMethod] - public void TryParse_WithAStrayArgument_Fails() - { - Assert.IsFalse(CommandLineParser.TryParse(["please"], out _, out string error)); - StringAssert.Contains(error, "please", StringComparison.Ordinal); - } - - [TestMethod] - public void TryParse_WithADurationThatIsNotADuration_Fails() - { - Assert.IsFalse(CommandLineParser.TryParse(["--for", "soon"], out _, out string error)); - StringAssert.Contains(error, "soon", StringComparison.Ordinal); - } - - [TestMethod] - public void TryParse_WithAValuelessOptionAtTheEnd_Fails() - { - Assert.IsFalse(CommandLineParser.TryParse(["--for"], out _, out string error)); - StringAssert.Contains(error, "needs a value", StringComparison.Ordinal); - } - - [TestMethod] - public void TryParse_WithNullArguments_Throws() => - Assert.ThrowsExactly(() => CommandLineParser.TryParse(null!, out _, out _)); -} diff --git a/NoSleep.Test/DurationParserTests.cs b/NoSleep.Test/DurationParserTests.cs deleted file mode 100644 index 1ce2a15..0000000 --- a/NoSleep.Test/DurationParserTests.cs +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Test; - -using System; -using ktsu.NoSleep.Tool.Cli; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -/// -/// Tests for . -/// -/// -/// The bare-number case matters most: --for 90 has to mean ninety minutes, which is the one reading -/// would never give it. -/// -[TestClass] -public class DurationParserTests -{ - [TestMethod] - [DataRow("45s", 45)] - [DataRow("45S", 45)] - [DataRow("45sec", 45)] - [DataRow("2m", 120)] - [DataRow("2min", 120)] - [DataRow("1h", 3600)] - [DataRow("1hr", 3600)] - [DataRow("1h30m", 5400)] - [DataRow("1h30m15s", 5415)] - [DataRow("1d", 86400)] - [DataRow("0.5h", 1800)] - [DataRow(" 90m ", 5400)] - public void TryParse_WithAValidDuration_ReturnsTheDuration(string text, int expectedSeconds) - { - Assert.IsTrue(DurationParser.TryParse(text, out TimeSpan duration)); - Assert.AreEqual(TimeSpan.FromSeconds(expectedSeconds), duration); - } - - [TestMethod] - public void TryParse_WithABareNumber_ReadsItAsMinutes() - { - Assert.IsTrue(DurationParser.TryParse("90", out TimeSpan duration)); - Assert.AreEqual(TimeSpan.FromMinutes(90), duration); - } - - [TestMethod] - [DataRow("")] - [DataRow(" ")] - [DataRow(null)] - [DataRow("0")] - [DataRow("0s")] - [DataRow("-5m")] - [DataRow("forever")] - [DataRow("5x")] - [DataRow("m5")] - [DataRow("5m5")] - [DataRow("1e9h")] - public void TryParse_WithAnInvalidDuration_ReturnsFalse(string? text) - { - Assert.IsFalse(DurationParser.TryParse(text, out TimeSpan duration)); - Assert.AreEqual(TimeSpan.Zero, duration); - } -} diff --git a/NoSleep.Test/PlatformIntegrationTests.cs b/NoSleep.Test/PlatformIntegrationTests.cs index 3b9fd84..181add8 100644 --- a/NoSleep.Test/PlatformIntegrationTests.cs +++ b/NoSleep.Test/PlatformIntegrationTests.cs @@ -7,7 +7,6 @@ namespace ktsu.NoSleep.Test; using System.Reflection; using ktsu.NoSleep.Contracts; using ktsu.NoSleep.Platforms; -using ktsu.NoSleep.Tool.Cli; using Microsoft.VisualStudio.TestTools.UnitTesting; using Tool = ktsu.NoSleep.Tool; @@ -87,51 +86,13 @@ public void TrayIcons_AreEmbeddedUnderTheNamesTheLoaderExpects() // The loader resolves these by string, so a moved or renamed asset only shows up as a tray icon that // fails to draw at run time. Checking the resource names keeps that a build-time failure instead, and // does not need a windowing system the way constructing a WindowIcon would. - Assembly toolAssembly = typeof(Tool.Tray.TrayIconAssets).Assembly; + Assembly toolAssembly = typeof(Tool.Program).Assembly; string[] resources = toolAssembly.GetManifestResourceNames(); CollectionAssert.Contains(resources, "ktsu.NoSleep.Tool.Assets.tray-active.png"); CollectionAssert.Contains(resources, "ktsu.NoSleep.Tool.Assets.tray-idle.png"); } - [TestMethod] - public void Status_DescribesThisMachine() - { - using ISleepBlocker blocker = SleepBlockerFactory.Create(); - - string report = Tool.StatusReport.Build(blocker); - - StringAssert.Contains(report, "Mechanism:", StringComparison.Ordinal); - StringAssert.Contains(report, blocker.Mechanism, StringComparison.Ordinal); - StringAssert.Contains(report, "Tray icon:", StringComparison.Ordinal); - } - - [TestMethod] - public void Usage_DocumentsEveryOptionTheParserAccepts() - { - string usage = HelpText.Usage; - - foreach (string option in new[] { "--tray", "--no-tray", "--display", "--off", "--for", "--reason", "--status", "--help", "--version" }) - { - StringAssert.Contains(usage, option, StringComparison.Ordinal); - } - } - - [TestMethod] - public void Version_ReportsTheToolName() => - Assert.IsTrue(HelpText.Version.StartsWith("nosleep ", StringComparison.Ordinal), HelpText.Version); - - [TestMethod] - public void DesktopSession_ExplanationAgreesWithAvailability() - { - string explanation = Tool.DesktopSession.Explanation; - - Assert.AreEqual( - Tool.DesktopSession.IsAvailable, - explanation.StartsWith("available", StringComparison.Ordinal), - explanation); - } - [TestMethod] public void LinuxBlocker_ReportsWhatTheMachineHasInstalled() { diff --git a/NoSleep.Tool/Cli/CommandLineOptions.cs b/NoSleep.Tool/Cli/CommandLineOptions.cs deleted file mode 100644 index 6d54fc4..0000000 --- a/NoSleep.Tool/Cli/CommandLineOptions.cs +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Cli; - -using System; - -/// -/// What the user asked for on the command line. -/// -public sealed class CommandLineOptions -{ - /// - /// Gets a value indicating whether the tray icon was explicitly asked for with --tray. - /// - /// - /// Distinct from "the tray is wanted": with neither switch given the tray is used when the machine has a - /// desktop session to put it in, and this says the user overrode that. - /// - public bool TrayRequested { get; init; } - - /// - /// Gets a value indicating whether --no-tray was given. - /// - public bool TraySuppressed { get; init; } - - /// - /// Gets a value indicating whether the display should be kept awake as well as the system. - /// - public bool KeepDisplayAwake { get; init; } - - /// - /// Gets a value indicating whether the tray should start with keep-awake turned off. - /// - public bool StartDisabled { get; init; } - - /// - /// Gets how long to stay awake before releasing and exiting, or to stay awake until - /// stopped. - /// - public TimeSpan? Duration { get; init; } - - /// - /// Gets a value indicating whether to print what NoSleep can do on this machine and exit. - /// - public bool ShowStatus { get; init; } - - /// - /// Gets a value indicating whether to print usage and exit. - /// - public bool ShowHelp { get; init; } - - /// - /// Gets a value indicating whether to print the version and exit. - /// - public bool ShowVersion { get; init; } - - /// - /// Gets the reason recorded with the inhibitor, shown by the platform's own tooling. - /// - public string Reason { get; init; } = SleepBlockRequest.DefaultReason; - - /// - /// Builds the request these options describe. - /// - /// The request to hand to a . - public SleepBlockRequest ToRequest() => new(KeepDisplayAwake, Reason); -} diff --git a/NoSleep.Tool/Cli/CommandLineParser.cs b/NoSleep.Tool/Cli/CommandLineParser.cs deleted file mode 100644 index 0ebe90e..0000000 --- a/NoSleep.Tool/Cli/CommandLineParser.cs +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Cli; - -using System; -using System.Collections.Generic; - -/// -/// Turns nosleep's arguments into . -/// -/// -/// The whole surface is eight switches with no subcommands, so it is parsed here rather than pulled in from a -/// parser library: a hand-written loop is smaller than the configuration one would take, and it keeps the -/// error messages in the same voice as the rest of the tool. -/// -public static class CommandLineParser -{ - /// - /// Parses the arguments. - /// - /// The raw arguments, as handed to Main. - /// Receives the parsed options when parsing succeeds. - /// Receives a message describing the problem when parsing fails. - /// when the arguments were understood. - /// is . - public static bool TryParse(IReadOnlyList args, out CommandLineOptions options, out string error) - { - Ensure.NotNull(args); - - bool tray = false; - bool noTray = false; - bool display = false; - bool startDisabled = false; - bool status = false; - bool help = false; - bool version = false; - TimeSpan? duration = null; - string reason = SleepBlockRequest.DefaultReason; - - for (int index = 0; index < args.Count; index++) - { - string argument = args[index]; - - switch (argument) - { - case "-t" or "--tray": - tray = true; - break; - - case "--no-tray": - noTray = true; - break; - - case "-d" or "--display": - display = true; - break; - - case "-o" or "--off": - startDisabled = true; - break; - - case "-s" or "--status": - status = true; - break; - - case "-h" or "--help" or "-?": - help = true; - break; - - case "-v" or "--version": - version = true; - break; - - case "-f" or "--for": - if (!TryTakeValue(args, ref index, argument, out string durationText, out error)) - { - options = new CommandLineOptions(); - return false; - } - - if (!DurationParser.TryParse(durationText, out TimeSpan parsedDuration)) - { - options = new CommandLineOptions(); - error = $"'{durationText}' is not a duration. Try 45s, 90m, 2h, or 1h30m."; - return false; - } - - duration = parsedDuration; - break; - - case "-r" or "--reason": - if (!TryTakeValue(args, ref index, argument, out reason, out error)) - { - options = new CommandLineOptions(); - return false; - } - - break; - - default: - options = new CommandLineOptions(); - error = argument.StartsWith('-') - ? $"Unknown option '{argument}'. Run 'nosleep --help' for the list." - : $"Unexpected argument '{argument}'. NoSleep takes options only."; - return false; - } - } - - if (tray && noTray) - { - options = new CommandLineOptions(); - error = "--tray and --no-tray contradict each other."; - return false; - } - - options = new CommandLineOptions - { - TrayRequested = tray, - TraySuppressed = noTray, - KeepDisplayAwake = display, - StartDisabled = startDisabled, - Duration = duration, - ShowStatus = status, - ShowHelp = help, - ShowVersion = version, - Reason = reason, - }; - - error = string.Empty; - return true; - } - - private static bool TryTakeValue(IReadOnlyList args, ref int index, string option, out string value, out string error) - { - if (index + 1 >= args.Count) - { - value = string.Empty; - error = $"'{option}' needs a value."; - return false; - } - - index++; - value = args[index]; - error = string.Empty; - return true; - } -} diff --git a/NoSleep.Tool/Cli/DurationParser.cs b/NoSleep.Tool/Cli/DurationParser.cs deleted file mode 100644 index 5e08deb..0000000 --- a/NoSleep.Tool/Cli/DurationParser.cs +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Cli; - -using System; -using System.Globalization; - -/// -/// Reads the duration accepted by --for. -/// -/// -/// reads 2 as two days and rejects 90m -/// outright, neither of which is what someone types when they want to stay awake through a build. This reads -/// the compound form instead: a run of number/unit pairs such as 1h30m, with a bare number meaning -/// minutes. -/// -public static class DurationParser -{ - /// - /// Parses a duration such as 45s, 90m, 2h, 1h30m, or a bare 90 for minutes. - /// - /// The text to read. - /// Receives the duration when parsing succeeds. - /// when is a positive duration. - public static bool TryParse(string? text, out TimeSpan duration) - { - duration = TimeSpan.Zero; - - if (string.IsNullOrWhiteSpace(text)) - { - return false; - } - - ReadOnlySpan span = text.AsSpan().Trim(); - double totalSeconds = 0; - int index = 0; - bool sawAnyPart = false; - - while (index < span.Length) - { - int digitsStart = index; - while (index < span.Length && (char.IsAsciiDigit(span[index]) || span[index] == '.')) - { - index++; - } - - if (index == digitsStart) - { - return false; - } - - if (!double.TryParse(span[digitsStart..index], NumberStyles.Float, CultureInfo.InvariantCulture, out double value) || value < 0) - { - return false; - } - - int unitStart = index; - while (index < span.Length && char.IsAsciiLetter(span[index])) - { - index++; - } - - // A bare number is only allowed when it is the whole value: "90" means ninety minutes, but "5m5" - // is a typo, not five minutes and five more. - bool isWholeValue = digitsStart == 0 && index == span.Length; - - if (!TryGetUnitSeconds(span[unitStart..index], isWholeValue, out double unitSeconds)) - { - return false; - } - - totalSeconds += value * unitSeconds; - sawAnyPart = true; - } - - if (!sawAnyPart || totalSeconds <= 0 || totalSeconds > TimeSpan.MaxValue.TotalSeconds) - { - return false; - } - - duration = TimeSpan.FromSeconds(totalSeconds); - return true; - } - - private static bool TryGetUnitSeconds(ReadOnlySpan unit, bool isWholeValue, out double seconds) - { - // A missing unit means minutes: "--for 90" is the shape people reach for, and minutes is the only - // reading of it that is ever useful. - seconds = unit.Length switch - { - 0 => isWholeValue ? 60 : 0, - _ when unit.Equals("s", StringComparison.OrdinalIgnoreCase) => 1, - _ when unit.Equals("sec", StringComparison.OrdinalIgnoreCase) => 1, - _ when unit.Equals("m", StringComparison.OrdinalIgnoreCase) => 60, - _ when unit.Equals("min", StringComparison.OrdinalIgnoreCase) => 60, - _ when unit.Equals("h", StringComparison.OrdinalIgnoreCase) => 3600, - _ when unit.Equals("hr", StringComparison.OrdinalIgnoreCase) => 3600, - _ when unit.Equals("d", StringComparison.OrdinalIgnoreCase) => 86400, - _ => 0, - }; - - return seconds > 0; - } -} diff --git a/NoSleep.Tool/Cli/HelpText.cs b/NoSleep.Tool/Cli/HelpText.cs deleted file mode 100644 index 3fd4e60..0000000 --- a/NoSleep.Tool/Cli/HelpText.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Cli; - -using System.Reflection; - -/// -/// The usage and version text. -/// -public static class HelpText -{ - /// - /// Gets the usage text printed for --help and for a bad command line. - /// - public static string Usage => - """ - nosleep - keep this machine awake. - - Usage: - nosleep [options] - - With no options NoSleep starts keeping the machine awake and shows a tray icon when the - session has somewhere to put one, falling back to staying in the terminal when it does not. - - Options: - -t, --tray Show the tray icon even if no desktop session was detected. - --no-tray Stay in the terminal; never show a tray icon. - -d, --display Keep the display lit as well, not just the system awake. - -o, --off Start with keep-awake switched off (tray only). - -f, --for Release and exit after a duration: 45s, 90m, 2h, 1h30m. - A bare number means minutes. - -r, --reason Reason recorded with the inhibitor, shown by the platform's - own tooling (pmset -g assertions, systemd-inhibit --list). - -s, --status Print what NoSleep can do on this machine, then exit. - -h, --help Show this help, then exit. - -v, --version Print the version, then exit. - - Examples: - nosleep Keep awake, with a tray icon if one is possible. - nosleep --no-tray --for 2h Keep awake for two hours from a terminal or a script. - nosleep --display --tray Keep the screen lit too, and show the tray icon. - nosleep --off Show the tray icon, but start switched off. - - Exit codes: 0 success, 1 the platform refused, 2 the command line was wrong. - """; - - /// - /// Gets the version line printed for --version. - /// - public static string Version - { - get - { - Assembly assembly = typeof(HelpText).Assembly; - string version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() - ?? "unknown"; - - // The SDK appends the source revision after a '+', which is noise in a version line. - int plus = version.IndexOf('+', System.StringComparison.Ordinal); - if (plus >= 0) - { - version = version[..plus]; - } - - return $"nosleep {version}"; - } - } -} diff --git a/NoSleep.Tool/ConsoleRunner.cs b/NoSleep.Tool/ConsoleRunner.cs deleted file mode 100644 index 284afbf..0000000 --- a/NoSleep.Tool/ConsoleRunner.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool; - -using System; -using System.Globalization; -using System.Runtime.InteropServices; -using System.Threading; -using ktsu.NoSleep.Tool.Cli; - -/// -/// The headless mode: hold the inhibitor and block until interrupted or until the duration runs out. -/// -/// -/// This is the mode a CI agent, an SSH session, or a script gets. It has no dependency on the windowing -/// stack, which is what makes nosleep --no-tray usable on a machine with no desktop at all. -/// -public static class ConsoleRunner -{ - /// - /// Keeps the machine awake until the process is asked to stop. - /// - /// The controller to drive. - /// The options the run was started with. - /// The process exit code. - /// or is . - public static int Run(KeepAwakeController controller, CommandLineOptions options) - { - Ensure.NotNull(controller); - Ensure.NotNull(options); - - try - { - controller.Enable(); - } - catch (Exception ex) when (ex is SleepBlockException or PlatformNotSupportedException) - { - Console.Error.WriteLine($"nosleep: {ex.Message}"); - return 1; - } - - using ManualResetEventSlim stopping = new(initialState: false); - - void RequestStop() => stopping.Set(); - - void OnCancelKeyPress(object? sender, ConsoleCancelEventArgs eventArgs) - { - // Take the interrupt rather than letting the runtime tear the process down, so the inhibitor is - // released through the normal path instead of being left to the operating system to clean up. - eventArgs.Cancel = true; - RequestStop(); - } - - Console.CancelKeyPress += OnCancelKeyPress; - - // SIGTERM is how a service manager or `docker stop` ends this, and it does not go through - // CancelKeyPress. - using PosixSignalRegistration termination = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context => - { - context.Cancel = true; - RequestStop(); - }); - - try - { - Console.WriteLine(DescribeRun(controller, options)); - - if (options.Duration is TimeSpan window) - { - stopping.Wait(window); - } - else - { - stopping.Wait(); - } - } - finally - { - Console.CancelKeyPress -= OnCancelKeyPress; - controller.Disable(); - } - - Console.WriteLine("nosleep: released. This machine may sleep again."); - return 0; - } - - private static string DescribeRun(KeepAwakeController controller, CommandLineOptions options) - { - string scope = controller.KeepDisplayAwake ? "system and display" : "system"; - string until = options.Duration is TimeSpan window - ? string.Create(CultureInfo.InvariantCulture, $"for {window:g}") - : "until interrupted"; - - return string.Create( - CultureInfo.InvariantCulture, - $"nosleep: keeping {scope} awake {until} via {controller.Mechanism}. Press Ctrl+C to stop."); - } -} diff --git a/NoSleep.Tool/DesktopSession.cs b/NoSleep.Tool/DesktopSession.cs deleted file mode 100644 index e681153..0000000 --- a/NoSleep.Tool/DesktopSession.cs +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool; - -using System; - -/// -/// Works out whether this process has a desktop session to put a tray icon in. -/// -/// -/// Starting the windowing subsystem on a machine with no display does not fail politely - it throws out of -/// Avalonia's platform initialisation, after NoSleep has already printed that it is keeping the machine awake. -/// Checking first lets a headless run fall back to the console instead. -/// -public static class DesktopSession -{ - /// - /// Gets a value indicating whether a tray icon can be shown. - /// - public static bool IsAvailable => !OperatingSystem.IsLinux() || HasLinuxDisplay; - - /// - /// Gets a sentence explaining , for --status and for the fallback notice. - /// - public static string Explanation - { - get - { - if (!OperatingSystem.IsLinux()) - { - return "available"; - } - - return HasLinuxDisplay - ? "available (DISPLAY or WAYLAND_DISPLAY is set)" - : "unavailable (neither DISPLAY nor WAYLAND_DISPLAY is set)"; - } - } - - private static bool HasLinuxDisplay => - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DISPLAY")) - || !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("WAYLAND_DISPLAY")); -} diff --git a/NoSleep.Tool/NoSleep.Tool.csproj b/NoSleep.Tool/NoSleep.Tool.csproj index e73a524..a3d42fc 100644 --- a/NoSleep.Tool/NoSleep.Tool.csproj +++ b/NoSleep.Tool/NoSleep.Tool.csproj @@ -3,24 +3,20 @@ - - - false - false - - - - - - - + + + + + + + + - @@ -29,44 +25,9 @@ - - - - ;win-x64;win-x86;win-arm64;osx;osx-x64;osx-arm64;linux-x64;linux-arm64;linux-arm; - - - - - <_ToolAsset Include="@(ResolvedFileToPublish)"> - $([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('\','/')) - - - - <_ToolRuntimeAsset Include="@(_ToolAsset)" Condition="$([System.String]::Copy('%(_ToolAsset.NormalizedPath)').StartsWith('runtimes/'))"> - $([System.String]::Copy('%(_ToolAsset.NormalizedPath)').Split('/')[1]) - - - <_ToolRuntimeAssetToDrop Include="@(_ToolRuntimeAsset)" Condition="!$(NoSleepToolRuntimeIdentifiers.Contains(';%(_ToolRuntimeAsset.Rid);'))" /> - - - <_ToolRuntimeAssetToDrop Include="@(_ToolRuntimeAsset)" Condition="$([System.String]::Copy('%(_ToolRuntimeAsset.NormalizedPath)').EndsWith('.pdb'))" /> - - - - diff --git a/NoSleep.Tool/Program.cs b/NoSleep.Tool/Program.cs index 5cfea1e..1592764 100644 --- a/NoSleep.Tool/Program.cs +++ b/NoSleep.Tool/Program.cs @@ -2,154 +2,162 @@ namespace ktsu.NoSleep.Tool; -using System; -using System.Diagnostics.CodeAnalysis; -using Avalonia; +using System.Threading.Tasks; +using ktsu.Essentials; +using ktsu.Essentials.FileSystemProviders.Native; +using ktsu.Essentials.PersistenceProviders.ConfigHome; +using ktsu.Essentials.SerializationProviders.Json; using ktsu.NoSleep.Contracts; -using ktsu.NoSleep.Tool.Cli; -using ktsu.NoSleep.Tool.Tray; +using ktsu.NoSleep.Platforms; +using ktsu.TrayApp; /// /// Entry point for the nosleep .NET tool. /// +/// +/// Everything that is not specific to keeping a machine awake - the tray host, the tray-versus-console +/// decision and its fallback, Ctrl+C and SIGTERM, the --for timer, the standard flags, the +/// --status report, and remembering what the tray was left set to - belongs to +/// ktsu.TrayApp. What is left here is the menu, the three switches NoSleep adds, and the wiring +/// between them and . +/// internal static class Program { - private const int ExitSuccess = 0; - private const int ExitFailure = 1; - private const int ExitUsage = 2; - - /// Set to 1 to print the full exception behind a tray failure. - private const string DebugEnvironmentVariable = "NOSLEEP_DEBUG"; - /// /// Runs the tool. /// /// Command line arguments. /// 0 on success, 1 when the platform refused, 2 when the command line was wrong. - internal static int Main(string[] args) + private static async Task Main(string[] args) { - if (!CommandLineParser.TryParse(args, out CommandLineOptions options, out string error)) + // The whole persistence wiring: a serializer, a file system, and a directory convention. + // ktsu.TrayApp takes the IPersistenceProvider that falls out of it and nothing else, so + // where NoSleep's settings live is NoSleep's decision rather than the library's. + IPersistenceProvider preferences = new ConfigHomePersistenceProvider( + new NativeFileSystemProvider(), + new JsonSerializationProvider(), + "nosleep", + string.Empty); + + // The blocker is created here rather than left to the controller so that --status can ask it + // whether this machine can keep the display lit, which is a Linux-only distinction the controller + // has no reason to carry. Declared first, so the controller is disposed before it. + using ISleepBlocker blocker = SleepBlockerFactory.Create(); + using KeepAwakeController controller = new(blocker); + + // What the user has asked for, which is not yet what the controller is doing: the toggle's setter, + // --off, and the remembered tray state all land here before the run starts, and OnStart is what + // turns the intent into a held inhibitor. Enabling straight from the setter instead would leave + // OnStart to override a remembered "off" - or an explicit --off - a moment later. + bool keepAwakeWanted = true; + bool started = false; + + void SetKeepAwake(bool value) { - Console.Error.WriteLine($"nosleep: {error}"); - Console.Error.WriteLine(); - Console.Error.WriteLine(HelpText.Usage); - return ExitUsage; + keepAwakeWanted = value; + + if (started) + { + ApplyKeepAwake(controller, value); + } } - if (options.ShowHelp) + void Start() { - Console.WriteLine(HelpText.Usage); - return ExitSuccess; + started = true; + + if (keepAwakeWanted) + { + controller.Enable(); + } } - if (options.ShowVersion) + return await TrayAppBuilder.Create("nosleep") + .DisplayName("NoSleep") + .Summary("keep this machine awake.") + .Icons(typeof(Program).Assembly, "Assets.tray-active.png", "Assets.tray-idle.png") + .Status(() => Describe(controller)) + .Status("Mechanism", () => controller.Mechanism) + .Status("Keep awake", () => controller.IsSupported ? "supported" : "not supported on this machine") + .Status("Keep display", () => DescribeDisplaySupport(blocker)) + .Toggle( + "Keep awake", + () => controller.IsActive, + SetKeepAwake, + () => controller.IsSupported, + persistAs: "keep-awake") + .Toggle( + "Keep display awake too", + () => controller.KeepDisplayAwake, + controller.SetKeepDisplayAwake, + () => controller.IsSupported, + persistAs: "keep-display-awake") + .Flag(["-d", "--display"], "Keep the display lit as well, not just the system awake.", () => controller.SetKeepDisplayAwake(true)) + .Flag(["-o", "--off"], "Start with keep-awake switched off.", () => keepAwakeWanted = false) + .Option( + ["-r", "--reason"], + "text", + "Reason recorded with the inhibitor, shown by the platform's own\ntooling (pmset -g assertions, systemd-inhibit --list).", + reason => controller.SetRequest(controller.Request with { Reason = reason })) + .RefreshOn(refresh => controller.StateChanged += (_, _) => refresh()) + .OnStart(Start) + .OnStop(controller.Disable) + .Preferences(preferences) + .RunAsync(args) + .ConfigureAwait(false); + } + + private static void ApplyKeepAwake(KeepAwakeController controller, bool keepAwake) + { + if (keepAwake) { - Console.WriteLine(HelpText.Version); - return ExitSuccess; + controller.Enable(); } - - if (options.ShowStatus) + else { - using ISleepBlocker blocker = SleepBlockerFactory.Create(); - Console.WriteLine(StatusReport.Build(blocker)); - return blocker.IsSupported ? ExitSuccess : ExitFailure; + controller.Disable(); } - - return UseTray(options) ? RunTray(options) : RunConsole(options); } /// - /// Decides between the tray and the console. + /// Says what NoSleep is doing, for the menu's status line, the tray tooltip, and the State row of + /// --status. /// + /// The controller being driven. + /// The sentence to show. /// - /// --no-tray always wins, and --tray overrides the detection for the machines where it - /// guesses wrong - a working D-Bus status-notifier host with no DISPLAY set, say. Otherwise the - /// tray is the default wherever there is a session to put it in. + /// A refusal is not described here: ktsu.TrayApp keeps the last failure's message and puts it in + /// the status line itself, because the refresh that runs after every action would overwrite anything + /// written straight into the menu. /// - private static bool UseTray(CommandLineOptions options) + private static string Describe(KeepAwakeController controller) { - if (options.TraySuppressed) + if (!controller.IsSupported) { - return false; + return $"NoSleep cannot keep this machine awake ({controller.Mechanism})"; } - return options.TrayRequested || DesktopSession.IsAvailable; - } - - private static int RunConsole(CommandLineOptions options) - { - using KeepAwakeController controller = new(); - controller.SetRequest(options.ToRequest()); - return ConsoleRunner.Run(controller, options); - } - - [SuppressMessage( - "Design", - "CA1031:Do not catch general exception types", - Justification = "Any failure to bring up a tray icon is recoverable by falling back to the console, and the windowing backends do not report those failures through a common exception type.")] - private static int RunTray(CommandLineOptions options) - { - // Get() is the singleton the static QueueSave/SaveIfRequired helpers write back, so the tray has to - // mutate that instance rather than a private LoadOrCreate() copy. - TrayPreferences preferences = TrayPreferences.Get(); - - // An explicit switch beats what the tray remembered; without one, the tray comes back the way it was - // left. - bool keepDisplayAwake = options.KeepDisplayAwake || preferences.KeepDisplayAwake; - bool startEnabled = !options.StartDisabled && preferences.KeepAwake; - - using KeepAwakeController controller = new(); - controller.SetRequest(options.ToRequest() with { KeepDisplayAwake = keepDisplayAwake }); - - if (startEnabled) + if (!controller.IsActive) { - try - { - controller.Enable(); - } - catch (Exception ex) when (ex is SleepBlockException or PlatformNotSupportedException) - { - // Not fatal: the tray still comes up so the user can see why, and try again once whatever was - // missing is installed. - Console.Error.WriteLine($"nosleep: {ex.Message}"); - } + return "NoSleep is off - this machine may sleep"; } - TrayApplication? app = null; + return controller.KeepDisplayAwake + ? "Keeping system and display awake" + : "Keeping system awake"; + } - try + private static string DescribeDisplaySupport(ISleepBlocker blocker) + { + if (!blocker.IsSupported) { - // StartWithClassicDesktopLifetime blocks until the tray quits, so the using above covers the - // controller for the whole run. - return AppBuilder.Configure(() => app = new TrayApplication(controller, preferences, options.Duration)) - .UsePlatformDetect() - .With(new MacOSPlatformOptions { ShowInDock = false }) - .StartWithClassicDesktopLifetime([], Avalonia.Controls.ShutdownMode.OnExplicitShutdown); + return "not supported on this machine"; } - catch (Exception ex) - { - if (Environment.GetEnvironmentVariable(DebugEnvironmentVariable) == "1") - { - Console.Error.WriteLine(ex.ToString()); - } - // The tray already ran and quit; this is Avalonia's teardown throwing after the fact (see - // TrayApplication.HasStarted). Restarting in the terminal here would keep the machine awake after - // the user asked NoSleep to stop. - if (app?.HasStarted == true) - { - return ExitSuccess; - } - - // Otherwise the windowing stack refused after the session check passed - a DISPLAY pointing at - // nothing, a missing libX11, no status-notifier host on the bus - and it reports those as plain - // exceptions of whatever type the backend felt like (X11 throws Exception("XOpenDisplay failed")). - // There is no useful list to filter on, and every one of them means the same thing here: no tray, - // so use the terminal. Letting it escape instead would kill a process the user asked to keep - // their machine awake. - Console.Error.WriteLine($"nosleep: could not show a tray icon ({ex.Message}). Staying in the terminal instead."); - controller.Dispose(); - return RunConsole(options); - } + // Windows and macOS keep the screen lit through the same call that keeps the system awake. Linux + // splits the two, and a box with only a logind lock can do one and not the other. + return OperatingSystem.IsLinux() && blocker is LinuxSleepBlocker linux && !linux.SupportsDisplay + ? "not supported (needs gnome-session-inhibit)" + : "supported"; } } diff --git a/NoSleep.Tool/StatusReport.cs b/NoSleep.Tool/StatusReport.cs deleted file mode 100644 index 48bf3a0..0000000 --- a/NoSleep.Tool/StatusReport.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool; - -using System; -using System.Runtime.InteropServices; -using System.Text; -using ktsu.NoSleep.Contracts; -using ktsu.NoSleep.Platforms; -using ktsu.NoSleep.Tool.Cli; - -/// -/// Builds the text behind nosleep --status. -/// -/// -/// Sleep inhibition fails quietly by nature - the machine simply sleeps anyway, hours later, with nobody -/// watching - so the tool needs a way to say up front what it can and cannot do here. -/// -public static class StatusReport -{ - /// - /// Describes what NoSleep can do on this machine. - /// - /// The blocker for the current platform. - /// A multi-line report, without a trailing newline. - /// is . - public static string Build(ISleepBlocker blocker) - { - Ensure.NotNull(blocker); - - StringBuilder report = new(); - report.AppendLine(HelpText.Version); - report.AppendLine($"Platform: {RuntimeInformation.OSDescription.Trim()} ({RuntimeInformation.RuntimeIdentifier})"); - report.AppendLine($"Mechanism: {blocker.Mechanism}"); - report.AppendLine($"Keep awake: {(blocker.IsSupported ? "supported" : "not supported on this machine")}"); - report.AppendLine($"Keep display: {DescribeDisplaySupport(blocker)}"); - report.Append($"Tray icon: {DesktopSession.Explanation}"); - - return report.ToString(); - } - - private static string DescribeDisplaySupport(ISleepBlocker blocker) - { - if (!blocker.IsSupported) - { - return "not supported on this machine"; - } - - // Windows and macOS keep the screen lit through the same call that keeps the system awake. Linux - // splits the two, and a box with only a logind lock can do one and not the other. - return OperatingSystem.IsLinux() && blocker is LinuxSleepBlocker linux && !linux.SupportsDisplay - ? "not supported (needs gnome-session-inhibit)" - : "supported"; - } -} diff --git a/NoSleep.Tool/Tray/TrayApplication.cs b/NoSleep.Tool/Tray/TrayApplication.cs deleted file mode 100644 index 170305f..0000000 --- a/NoSleep.Tool/Tray/TrayApplication.cs +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Tray; - -using System; -using System.Globalization; -using Avalonia; -using Avalonia.Controls; -using Avalonia.Controls.ApplicationLifetimes; -using Avalonia.Threading; - -/// -/// The tray icon: one icon, one menu, no windows. -/// -/// -/// -/// Avalonia gives all three desktop platforms the same tray API - Shell_NotifyIcon on Windows, -/// NSStatusItem on macOS, and a StatusNotifierItem over D-Bus on Linux - which is the whole reason it -/// is here. Nothing else in NoSleep needs a UI framework. -/// -/// -/// There is no XAML: the app builds its menu in code and never calls AvaloniaXamlLoader, so -/// has nothing to do. A tray icon and a native menu need no styles or theme, and -/// skipping XAML keeps the tool's payload and its startup cost down. -/// -/// -public sealed class TrayApplication : Application, IDisposable -{ - private readonly KeepAwakeController controller; - private readonly TrayPreferences preferences; - private readonly TimeSpan? duration; - - // The menu items exist from construction so the refresh path has nothing to null-check; only the tray - // icon itself has to wait, because its constructor reaches for a platform handle that does not exist - // until Avalonia has finished starting. - private readonly NativeMenuItem statusItem = new() { IsEnabled = false }; - private readonly NativeMenuItem keepAwakeItem = new("Keep awake") { ToggleType = MenuItemToggleType.CheckBox }; - private readonly NativeMenuItem displayItem = new("Keep display awake too") { ToggleType = MenuItemToggleType.CheckBox }; - private readonly NativeMenuItem quitItem = new("Quit NoSleep"); - - private TrayIcon? trayIcon; - private DispatcherTimer? expiryTimer; - private string? lastError; - private bool disposed; - - /// - /// Initializes a new instance of the class. - /// - /// The controller the menu drives. - /// Where the menu's state is remembered between runs. - /// How long to stay awake before quitting, or to stay until asked to stop. - /// or is . - public TrayApplication(KeepAwakeController controller, TrayPreferences preferences, TimeSpan? duration) - { - Ensure.NotNull(controller); - Ensure.NotNull(preferences); - - this.controller = controller; - this.preferences = preferences; - this.duration = duration; - } - - /// - /// Gets a value indicating whether the tray icon came up. - /// - /// - /// Avalonia's Linux tray backend watches the D-Bus status-notifier host from an async void method - /// whose exception filter stops applying once the icon is disposed, so a perfectly ordinary shutdown can - /// throw a out of the run loop after the tray - /// has already done its job. This is how the caller tells that apart from a tray that never started. - /// - public bool HasStarted { get; private set; } - - /// - public override void Initialize() => Name = "NoSleep"; - - /// - public override void OnFrameworkInitializationCompleted() - { - keepAwakeItem.Click += OnKeepAwakeClicked; - displayItem.Click += OnKeepDisplayClicked; - quitItem.Click += OnQuitClicked; - - NativeMenu menu = - [ - statusItem, - new NativeMenuItemSeparator(), - keepAwakeItem, - displayItem, - new NativeMenuItemSeparator(), - quitItem, - ]; - - trayIcon = new TrayIcon { Menu = menu, IsVisible = true }; - - // A left click is the fastest way to flip the switch on Windows. Linux status-notifier hosts and - // macOS mostly open the menu instead, which is why the menu carries the same toggle. - trayIcon.Clicked += OnTrayIconClicked; - - // Registering the icon with the application is what disposes it on shutdown; a bare TrayIcon left - // behind outlives the process on some Linux panels. - TrayIcon.SetIcons(this, [trayIcon]); - - controller.StateChanged += OnControllerStateChanged; - ApplyDuration(); - RefreshMenu(); - - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Exit += OnExit; - } - - HasStarted = true; - - base.OnFrameworkInitializationCompleted(); - } - - private void OnTrayIconClicked(object? sender, EventArgs e) => ToggleKeepAwake(); - - private void OnKeepAwakeClicked(object? sender, EventArgs e) => ToggleKeepAwake(); - - private void OnKeepDisplayClicked(object? sender, EventArgs e) - { - bool keepDisplayAwake = !controller.KeepDisplayAwake; - - if (TryRun(() => controller.SetKeepDisplayAwake(keepDisplayAwake))) - { - preferences.KeepDisplayAwake = keepDisplayAwake; - TrayPreferences.QueueSave(); - } - - RefreshMenu(); - } - - private void OnQuitClicked(object? sender, EventArgs e) => Shutdown(); - - private void OnControllerStateChanged(object? sender, KeepAwakeStateChangedEventArgs e) => - Dispatcher.UIThread.Post(RefreshMenu); - - private void OnExit(object? sender, ControlledApplicationLifetimeExitEventArgs e) => Dispose(); - - /// - /// Stops the tray icon and flushes any pending preference write. - /// - /// - /// The controller is not disposed here: it belongs to the caller that started this run, and disposing it - /// from the exit handler would release the inhibitor twice on the fallback path. - /// - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - - expiryTimer?.Stop(); - controller.StateChanged -= OnControllerStateChanged; - TrayPreferences.SaveIfRequired(); - trayIcon?.Dispose(); - trayIcon = null; - } - - private void ToggleKeepAwake() - { - if (TryRun(() => controller.Toggle())) - { - preferences.KeepAwake = controller.IsActive; - TrayPreferences.QueueSave(); - } - - RefreshMenu(); - } - - private void ApplyDuration() - { - if (duration is not TimeSpan window) - { - return; - } - - expiryTimer = new DispatcherTimer { Interval = window }; - expiryTimer.Tick += (_, _) => - { - expiryTimer.Stop(); - controller.Disable(); - Shutdown(); - }; - - expiryTimer.Start(); - } - - private void Shutdown() - { - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) - { - desktop.Shutdown(); - return; - } - - // Nothing else can end a tray-only run, so fall back to ending the dispatcher loop directly rather - // than leaving a process with no way out. - Dispatcher.UIThread.InvokeShutdown(); - } - - private void RefreshMenu() - { - bool active = controller.IsActive; - string state = DescribeState(active); - - statusItem.Header = state; - - keepAwakeItem.IsChecked = active; - keepAwakeItem.IsEnabled = controller.IsSupported; - - displayItem.IsChecked = controller.KeepDisplayAwake; - displayItem.IsEnabled = controller.IsSupported; - - if (trayIcon is not null) - { - trayIcon.Icon = TrayIconAssets.For(active); - trayIcon.ToolTipText = state; - } - } - - private string DescribeState(bool active) - { - if (lastError is not null) - { - return $"NoSleep failed: {lastError}"; - } - - if (!controller.IsSupported) - { - return $"NoSleep cannot keep this machine awake ({controller.Mechanism})"; - } - - if (!active) - { - return "NoSleep is off - this machine may sleep"; - } - - string scope = controller.KeepDisplayAwake ? "system and display" : "system"; - return duration is TimeSpan window - ? string.Create(CultureInfo.InvariantCulture, $"Keeping {scope} awake for {DescribeDuration(window)}") - : $"Keeping {scope} awake"; - } - - private static string DescribeDuration(TimeSpan window) => - window.TotalHours >= 1 - ? string.Create(CultureInfo.InvariantCulture, $"{window.TotalHours:0.#}h") - : string.Create(CultureInfo.InvariantCulture, $"{window.TotalMinutes:0.#}m"); - - /// - /// Runs a menu action, turning a platform refusal into a message in the menu. - /// - /// - /// A tray app has no console to fail into and no window to raise a dialog over, so a refusal has to land - /// somewhere the user is already looking. It is also written to standard error for anyone who started - /// NoSleep from a terminal. - /// - private bool TryRun(Action action) - { - try - { - action(); - lastError = null; - return true; - } - catch (Exception ex) when (ex is SleepBlockException or PlatformNotSupportedException or ObjectDisposedException) - { - // RefreshMenu runs right after every action, so the message is kept in a field rather than - // written straight into the menu item, where the refresh would immediately overwrite it. - lastError = ex.Message; - Console.Error.WriteLine($"nosleep: {ex.Message}"); - return false; - } - } -} diff --git a/NoSleep.Tool/Tray/TrayIconAssets.cs b/NoSleep.Tool/Tray/TrayIconAssets.cs deleted file mode 100644 index 23e13ab..0000000 --- a/NoSleep.Tool/Tray/TrayIconAssets.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool.Tray; - -using System; -using System.IO; -using System.Reflection; -using Avalonia.Controls; - -/// -/// The two tray images, loaded once from the assembly's embedded resources. -/// -/// -/// The images are embedded rather than shipped beside the assembly because a dotnet tool is installed as a -/// package payload, and a loose PNG next to the dll is one more thing that can go missing on a machine -/// NoSleep will never be debugged on. -/// -public static class TrayIconAssets -{ - private const string ResourcePrefix = "ktsu.NoSleep.Tool.Assets."; - - private static readonly Lazy ActiveIcon = new(() => Load("tray-active.png")); - private static readonly Lazy IdleIcon = new(() => Load("tray-idle.png")); - - /// - /// Gets the icon shown while NoSleep is holding an inhibitor: an open eye. - /// - public static WindowIcon Active => ActiveIcon.Value; - - /// - /// Gets the icon shown while the machine is free to sleep: a closed eye. - /// - public static WindowIcon Idle => IdleIcon.Value; - - /// - /// Gets the icon for a state. - /// - /// Whether NoSleep is currently holding an inhibitor. - /// The matching icon. - public static WindowIcon For(bool isActive) => isActive ? Active : Idle; - - private static WindowIcon Load(string fileName) - { - string resourceName = ResourcePrefix + fileName; - Assembly assembly = typeof(TrayIconAssets).Assembly; - - using Stream stream = assembly.GetManifestResourceStream(resourceName) - ?? throw new InvalidOperationException( - $"The tray icon '{resourceName}' is missing from {assembly.GetName().Name}. Available resources: {string.Join(", ", assembly.GetManifestResourceNames())}."); - - return new WindowIcon(stream); - } -} diff --git a/NoSleep.Tool/TrayPreferences.cs b/NoSleep.Tool/TrayPreferences.cs deleted file mode 100644 index b7967f9..0000000 --- a/NoSleep.Tool/TrayPreferences.cs +++ /dev/null @@ -1,25 +0,0 @@ -// Copyright (c) 2026 ktsu-dev contributors - -namespace ktsu.NoSleep.Tool; - -using ktsu.AppDataStorage; - -/// -/// The tray's remembered state, persisted as JSON under the user's application data directory. -/// -/// -/// Only the tray reads this. A command line run is explicit about what it wants, so letting a remembered -/// setting override an argument would be surprising; the console path never touches it. -/// -public sealed class TrayPreferences : AppData -{ - /// - /// Gets or sets a value indicating whether the display was being kept awake when NoSleep last exited. - /// - public bool KeepDisplayAwake { get; set; } - - /// - /// Gets or sets a value indicating whether keep-awake was switched on when NoSleep last exited. - /// - public bool KeepAwake { get; set; } = true; -} diff --git a/README.md b/README.md index c1041e7..7ed752f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ operating system knows what is happening and its own tooling can report it. On a session, the same command falls back to a headless console mode that a script, a CI agent, or a systemd unit can use. +The library here is the inhibitor layer. The tray icon, the tray-versus-terminal decision, the standard +flags and the signal handling around them come from [`ktsu.TrayApp`](https://github.com/ktsu-dev/TrayApp), +which was extracted from this tool. + ## Features - **Real inhibitors, not input faking**: `SetThreadExecutionState` on Windows, IOKit power assertions on @@ -37,8 +41,8 @@ unit can use. awake forever. - **Nothing left behind**: the inhibitor is released on exit, and the Linux helper process is tied to NoSleep's own lifetime so a kill -9 releases the lock rather than orphaning it. -- **Remembered state**: the tray comes back the way it was left, while an explicit command line argument - always wins. +- **Remembered state**: the tray comes back the way it was left - stored under the user's config home as + `nosleep/tray.json` - while an explicit command line argument always wins. - **Honest about what it can do**: `nosleep --status` says which mechanism is available on this machine, whether the display can be kept lit, and whether a tray icon is possible. @@ -109,10 +113,11 @@ nosleep --status ``` nosleep 1.0.0 Platform: Ubuntu 24.04.4 LTS (ubuntu.24.04-x64) +Tray icon: available (DISPLAY or WAYLAND_DISPLAY is set) +State: NoSleep is off - this machine may sleep Mechanism: systemd-inhibit + gnome-session-inhibit Keep awake: supported Keep display: supported -Tray icon: available (DISPLAY or WAYLAND_DISPLAY is set) ``` ### Options @@ -122,8 +127,8 @@ Tray icon: available (DISPLAY or WAYLAND_DISPLAY is set) | `-t`, `--tray` | Show the tray icon even if no desktop session was detected. | | `--no-tray` | Stay in the terminal; never show a tray icon. | | `-d`, `--display` | Keep the display lit as well, not just the system awake. | -| `-o`, `--off` | Start with keep-awake switched off (tray only). | -| `-f`, `--for ` | Release and exit after a duration: `45s`, `90m`, `2h`, `1h30m`. A bare number means minutes. | +| `-o`, `--off` | Start with keep-awake switched off. | +| `-f`, `--for ` | Stop and exit after a duration: `45s`, `90m`, `2h`, `1h30m`. A bare number means minutes. | | `-r`, `--reason ` | Reason recorded with the inhibitor, shown by the platform's own tooling. | | `-s`, `--status` | Print what NoSleep can do on this machine, then exit. | | `-h`, `--help` | Show usage, then exit. | @@ -133,7 +138,10 @@ With neither `--tray` nor `--no-tray`, NoSleep shows a tray icon when the sessio and stays in the terminal when it does not. If the windowing stack refuses after that check passes, it says so and carries on in the terminal rather than exiting with the machine left awake. -Set `NOSLEEP_DEBUG=1` to print the full exception behind a tray failure. +`--status` describes the machine and exits 0 whether or not an inhibitor is available; read the +`Keep awake:` row rather than the exit code to tell those apart. + +Set `TRAYAPP_DEBUG=1` to print the full exception behind a tray failure. ## Usage Examples