diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 01bfecb..c0eb4e7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -23,6 +23,14 @@ jobs:
run: bash scripts/verify.sh
- name: Website browser checks
run: bash scripts/verify-website-browsers.sh
+ - name: Upload crash reports
+ if: failure()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: crash-reports
+ path: ~/Library/Logs/DiagnosticReports/
+ if-no-files-found: ignore
+ retention-days: 7
- name: Upload coverage to Codecov
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
with:
diff --git a/Package.swift b/Package.swift
index ae25b4a..1a38239 100644
--- a/Package.swift
+++ b/Package.swift
@@ -35,6 +35,8 @@ let package = Package(
.testTarget(name: "DilatedControlTests", dependencies: ["DilatedControl"]),
.testTarget(
name: "DilatedUITests",
- dependencies: ["DilatedUI", .product(name: "ViewInspector", package: "ViewInspector")]),
+ dependencies: [
+ "DilatedUI", "DilatedCLI", .product(name: "ViewInspector", package: "ViewInspector"),
+ ]),
]
)
diff --git a/README.md b/README.md
index 07c51dc..1a694f2 100644
--- a/README.md
+++ b/README.md
@@ -1,122 +1,177 @@
-# Dilated
-
-[](https://codecov.io/gh/CodesWhat/dilated/branch/main)
-
-Keeps your Mac awake. A native macOS 26 menu bar app for Apple Silicon.
-
-From [CodesWhat](https://codeswhat.com): small, sharp tools from one developer and a lot of agents.
-
-The local preview includes timed and indefinite awake holds, display-sleep controls,
-battery and Low Power Mode guards, four menu bar glyphs, optional compact/clock countdowns or end time, Settings,
-configurable global shortcuts, local history and statistics, URL automation, AppleScript,
-four Shortcuts actions, and
-a CLI with renewable agent holds, file/folder download watches, opt-in Drive Alive,
-notifications and sounds.
-Closed-lid controls and their privileged helper are implemented, with hardware
-recovery and release acceptance still pending.
-Condition-based triggers are implemented locally for apps, processes, schedules,
-power, displays, network identity, Wi-Fi SSIDs, mounted volumes, USB and paired
-Bluetooth devices, selected audio output,
-CPU load and idle time.
-Automatic agent setup remains later work.
-
-1.0.0-beta.8 is a public beta for macOS 26+ on Apple Silicon. Install it from the
-website download or with `brew install --cask codeswhat/tap/dilated`. Closed-lid
-mode is experimental, and crash, reboot and update recovery haven't been verified
-on hardware yet. Report issues on GitHub.
-
-Build the local app with `bash scripts/build-app.sh`, then open
-`build/Dilated.app`. This is a development build, not a notarized release. The
-script uses ad-hoc signing by default; `DILATED_SIGN_IDENTITY` can select an
-installed signing identity. Xcode and XcodeGen are required. The app shell extracts
-App Intents metadata while the core, UI, CLI and helper remain Swift packages.
-`bash scripts/build-app.sh debug --verify` safely stops the running dev copy,
-rebuilds it, launches it and reports its PID. It refuses alternate output paths
-and waits for other Dilated copies to be quit first. Add `--logs` to stream app
-logs for five minutes after launch.
-
-Real Shortcuts execution requires a developer-signed build on the tested Mac;
-an ad-hoc build can appear in the action catalog but fail to run.
-
-Set up the commit hooks once per clone with `brew install lefthook gitleaks && lefthook install`.
-
-Run the checks with `bash scripts/verify.sh` using Xcode, XcodeGen, Python 3, Ruby
-and Node.js 24. This entry point runs Swift tests, release packaging checks
-(including an isolated app build/launch), website tests, and formatting/syntax
-checks. CI uses the same entry point. After building the bundle, run the
-bounded app/CLI integration check with `python3 scripts/smoke-test.py`. It uses
-disposable state and cleans up its own processes. macOS UI checks are performed
-against the actual app.
-
-The package app product and internal executable are named `DilatedApp` to avoid
-a case-only collision with the `dilated` CLI on macOS filesystems. The app shown
-to users is Dilated.
-
-[Star History](https://www.star-history.com/?repos=CodesWhat%2Fdilated&type=date&legend=top-left)
-tracks the repository's stars.
-
-See [installation and removal](docs/INSTALLATION.md),
-[automation](docs/AUTOMATION.md), [architecture](docs/ARCHITECTURE.md), and
-[third-party notices](docs/THIRD-PARTY.md). Dilated is free software under MIT.
-
-Choose **Keep awake for → While a file downloads…** or **While a folder downloads…**
-from the menu. A folder watches its `.crdownload`, `.part`, `.download` and
-`.partial` items, including browser download packages. Choose 5–3600 seconds of
-inactivity before ending the hold. Stalled downloads also end it. Completion,
-Stop, safety guards and Quit release the watch without ending separate holds.
-Download watches aren't restored after a crash, relaunch or update; choose them
-again when needed. File access is requested only through your selection.
-
-In **Settings → Drive Alive**, select volumes, enable it and choose a 30–3600
-second interval. Volumes inherit the default unless you choose **While keeping
-awake** or **Always while open** for that volume. Always doesn't create an awake
-hold. Existing choices keep their previous behavior. Stop pauses every volume
-until Resume automation, and Off prevents all writes. Each pulse creates a new hidden
-8-byte file, flushes it and removes it. Removed, ambiguous, read-only or
-unwritable volumes show an error and aren't written to. Your existing files
-aren't opened for writing. A drive's own firmware or server policy may still let
-it sleep; Drive Alive doesn't wake a sleeping Mac.
-
-In **Settings → Battery & Safety**, the ordinary battery floor ignores AC power
-by default. Turn off **Ignore battery floor on AC** to enforce it while connected
-to power too. Changing the choice immediately checks existing holds. **Start
-Anyway** overrides the floor for one hold only; Low Power Mode and closed-lid
-safety limits remain separate.
-
-If settings can't be read, no saved timers restore and new holds stay blocked.
-Settings offers an explicit reset that privately backs up the unreadable file,
-saves the paused state, then writes defaults. Automation remains paused until
-you choose **Resume automation**.
-
-In **Settings → Notifications & Sounds**, enable banners to request macOS
-notification permission. Session and trigger starts/ends, safety reasons and
-periodic awake reminders are configurable. Agent/script events are excluded by
-default, and lease renewals stay quiet. Banners and sounds default off. Sounds
-are independent of banners, with original short cues and custom audio import
-(up to 2 MB and 10 seconds). Stop/final expiry ends reminders, and Quit cancels
-pending feedback. Failed notification delivery or sound playback never changes
-an awake hold. Native delivery and audible playback acceptance remain pending.
-
-In **Settings → Menu Bar**, import a static active PNG with a transparent
-background (up to 512 × 512 pixels and 2 MB). Add an optional idle PNG for a distinct
-resting state, or use the active PNG for both. Both stay still, use crisp template
-images and follow the menu bar's color. Idle dimming has a separate preference.
-Remove idle PNG returns to the active image; Remove icons discards both imported
-copies and selects the last bundled icon. Replacement/removal preserves any copy
-still used by the other state. A missing idle image falls back to the active
-image with a warning; a broken active image falls back to the bundled icon.
-
-**Settings → Menu Bar** also offers compact, clock and end-time text. Seconds can
-follow the format default, always show or stay hidden. Hidden seconds round
-remaining minutes up; the clock then reads hours:minutes. End time follows your
-Mac's locale and time zone and includes a date when the hold ends on another
-day. Choose the Mac's clock setting or force 12/24-hour end times, plus small or
-standard time text. Countdown durations keep their usual format. Indefinite
-holds still show ∞, and session time can be hidden entirely.
-
-For a closed-lid trigger, enable **Work with the lid closed** in its editor,
-save, then choose **Arm closed-lid** on that rule. This requires the approved
-helper. Arming applies only to this app run; relaunch, Stop, rule edits, helper
-changes and safety stops disarm it. Ordinary condition changes can start/end
-that trigger's own closed-lid request while it stays armed. Disarming one rule
-keeps other awake reasons running. Physical helper/trigger acceptance is pending.
+
+
+
+
+
Dilated
+
+**Keeps your Mac awake. A native macOS 26 menu bar app for Apple Silicon.**
+
+Timed holds, triggers, a CLI for agents, and an opt-in closed-lid mode.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!IMPORTANT]
+> **1.0.0-beta.8 is a public beta.** Closed-lid mode is experimental. Crash, reboot and update recovery haven't been verified on hardware yet, so don't rely on closed-lid mode for anything you can't afford to interrupt. Report problems on [GitHub](https://github.com/CodesWhat/dilated/issues).
+
+Contents
+
+- [Documentation](#documentation)
+- [Quick Start](#quick-start)
+- [Why Dilated](#why-dilated)
+- [Features](#features)
+- [Privacy and Permissions](#privacy-and-permissions)
+- [Roadmap](#roadmap)
+- [Build and Contribute](#build-and-contribute)
+- [Community and Support](#community-and-support)
+- [Star History](#star-history)
+- [CodesWhat Ecosystem](#codeswhat-ecosystem)
+- [License](#license)
+
+
+
+Documentation
+
+| Resource | Link |
+| --- | --- |
+| Website | [dilated.codeswhat.com](https://dilated.codeswhat.com/) |
+| Download | [dilated.codeswhat.com/download/file](https://dilated.codeswhat.com/download/file) |
+| Installation and removal | [docs/INSTALLATION.md](docs/INSTALLATION.md) |
+| Automation (CLI, URLs, AppleScript, Shortcuts, triggers) | [docs/AUTOMATION.md](docs/AUTOMATION.md) |
+| Architecture | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
+| Release procedure | [docs/RELEASING.md](docs/RELEASING.md) |
+| Third-party notices | [docs/THIRD-PARTY.md](docs/THIRD-PARTY.md) |
+| Releases | [github.com/CodesWhat/dilated/releases](https://github.com/CodesWhat/dilated/releases) |
+
+
+
+Quick Start
+
+Dilated needs **macOS 26 or later on Apple Silicon**. The current release is 1.0.0-beta.8, a public beta.
+
+1. Download the signed, notarized DMG from [dilated.codeswhat.com](https://dilated.codeswhat.com/download/file).
+2. Open the DMG, drag Dilated to Applications and open it.
+3. Pick **Keep awake for** from the menu bar item.
+
+Prefer Homebrew? `brew install --cask codeswhat/tap/dilated` installs the same build.
+
+Closed-lid mode needs a privileged helper and your approval in macOS. The [installation guide](docs/INSTALLATION.md) covers helper setup, updates and removal.
+
+
+
+Why Dilated
+
+Sometimes a Mac needs to stay awake: a long build, a download, a backup, an agent working through a task. Dilated does that from the menu bar, and the [website](https://dilated.codeswhat.com/) has an interactive preview of the menu. You can hold for a set time or until you stop it, let a trigger do it when a condition is true, or call it from a script.
+
+Battery floor and Low Power Mode guards can end a hold before it drains your battery, and Stop releases everything and pauses automation until you resume it. It's a beta, and closed-lid mode is the part with the least hardware testing.
+
+
+
+Features
+
+| Area | What it does |
+| --- | --- |
+| **Holds and timers** | Timed and indefinite awake holds from the menu. Downloads can drive a hold too: choose **While a file downloads…** or **While a folder downloads…**, with 5 to 3600 seconds of inactivity before it ends. |
+| **Display sleep** | Choose whether the display may sleep during a hold. |
+| **Guards** | A battery floor (ignored on AC by default, configurable in **Settings → Battery & Safety**) and Low Power Mode guards. **Start Anyway** overrides the floor for one hold. Closed-lid has its own safety limits. |
+| **Menu bar** | Four bundled glyphs, or your own static PNG icons (up to 512 × 512 pixels and 2 MB, separate idle image optional). Optional compact, clock or end-time text, with seconds shown or hidden, 12/24-hour choice and small or standard size. Indefinite holds show ∞. |
+| **Triggers** | Named rules that keep the Mac awake while all their conditions match. Conditions: running or frontmost apps, processes, schedules, power, displays, network identity, Wi-Fi SSIDs, mounted volumes, USB and paired Bluetooth devices, selected audio output, CPU load and idle time. Dwell and grace delays. |
+| **Automation** | URL scheme, AppleScript, four Shortcuts actions and a `dilated` CLI. The CLI covers `on`, `off`, `status`, `run -- ` and renewable agent holds (`hold`, `release`). Configurable global shortcuts. |
+| **Drive Alive** | Opt-in. Writes a small hidden file to selected volumes at an interval you choose (30 to 3600 seconds) so they don't idle. It doesn't wake a sleeping Mac. |
+| **Closed-lid mode** | Keeps the Mac running with the lid closed, through a privileged helper you approve. Experimental. A closed-lid trigger has to be armed each app run. |
+| **Feedback** | Optional notification banners and sounds, both off by default. Local history and statistics. |
+
+The [automation reference](docs/AUTOMATION.md) has every command, trigger condition and safety rule.
+
+
+
+Privacy and Permissions
+
+- **Location** is requested only to match the Wi-Fi network names you choose for triggers. Dilated doesn't request or record your geographic location.
+- **Bluetooth** is used only to match paired devices you choose for triggers. It doesn't scan for nearby devices or change their connections.
+- **Closed-lid mode** needs the privileged helper, which you approve in macOS.
+- **Notifications** are requested only when you enable banners.
+- **Files** are accessed only through your selection, for example a download watch or a Drive Alive volume.
+
+History and statistics stay on your Mac. The only network use in the app code is Sparkle's update check against `dilated.codeswhat.com`. There's no analytics code in it.
+
+
+
+Roadmap
+
+1.0.0 GA is gated on the hardware recovery checks listed in the [installation guide](docs/INSTALLATION.md#release-acceptance): helper and app crash recovery, reboot recovery, helper upgrade and the closed-lid transitions. Automatic agent setup is later work.
+
+
+
+Build and Contribute
+
+Build the local app with `bash scripts/build-app.sh`, then open `build/Dilated.app`. This is a development build, not a notarized release. The script uses ad-hoc signing by default, and `DILATED_SIGN_IDENTITY` can select an installed signing identity. Xcode and XcodeGen are required. Real Shortcuts execution needs a developer-signed build on the tested Mac. An ad-hoc build can show up in the action catalog but fail to run.
+
+`bash scripts/build-app.sh debug --verify` stops the running dev copy, rebuilds it, launches it and reports its PID. It refuses alternate output paths and waits for other Dilated copies to be quit first. Add `--logs` to stream app logs for five minutes after launch.
+
+Set up the commit hooks once per clone:
+
+```sh
+brew install lefthook gitleaks && lefthook install
+```
+
+Run the checks with `bash scripts/verify.sh`. You need Xcode, XcodeGen, Python 3, Ruby and Node.js 24. It runs Swift tests, release packaging checks (including an isolated app build and launch), website tests, and formatting and syntax checks. CI uses the same entry point. After building the bundle, `python3 scripts/smoke-test.py` runs the bounded app and CLI integration check with disposable state.
+
+The package app product and internal executable are named `DilatedApp` so they don't collide with the `dilated` CLI on case-insensitive macOS filesystems. The app users see is Dilated.
+
+
+
+Community and Support
+
+Open an [issue](https://github.com/CodesWhat/dilated/issues) for bugs. Include the Dilated and macOS versions and steps to reproduce. Report security problems through GitHub's [private vulnerability reporting](https://github.com/CodesWhat/dilated/security/advisories/new), not in a public issue.
+
+
+
+Star History
+
+
+
+
+
+CodesWhat Ecosystem
+
+
+ Tool Role
+ dilated Keeps your Mac awake. Menu bar app with triggers and a CLI
+ mouthtype Local dictation for Mac. Hold Fn, speak, release to paste
+ drydock Container update monitoring, web UI and notification engine
+ portwing Remote Docker agent, secure socket-level access from Drydock or standalone
+ sockguard Docker socket proxy, default-deny allowlist filter protecting the socket
+
+
+Dilated and Mouthtype are the two desktop apps here. The other three tools are for Docker hosts.
+
+
+
+License
+
+Dilated is licensed under [MIT](LICENSE). Dependencies have their own terms, recorded in [docs/THIRD-PARTY.md](docs/THIRD-PARTY.md).
+
+[CodesWhat](https://codeswhat.com): small, sharp tools from one developer and a lot of agents.
+
+
diff --git a/Sources/DilatedCLI/DilatedCommand.swift b/Sources/DilatedCLI/DilatedCommand.swift
index 40bc52e..6aebf13 100644
--- a/Sources/DilatedCLI/DilatedCommand.swift
+++ b/Sources/DilatedCLI/DilatedCommand.swift
@@ -6,6 +6,9 @@ import Foundation
@main struct DilatedCommand {
@MainActor static func main() async {
+ if let launch = ClosedLidWatchdogCommand.parse(Array(CommandLine.arguments.dropFirst())) {
+ exit(await ClosedLidWatchdogCommand.run(launch))
+ }
do {
let input = try CLIInput.parse(Array(CommandLine.arguments.dropFirst()))
if input.help {
@@ -46,7 +49,9 @@ import Foundation
do {
response = try LocalSocketClient.send(request, to: endpoint)
} catch let error as AppConnectionUnavailable {
- guard ![.status, .helperStatus].contains(request.action) else { throw error }
+ guard ![.status, .helperStatus, .lidPermissionStatus].contains(request.action) else {
+ throw error
+ }
try await launchApp(endpoint: endpoint)
response = try LocalSocketClient.send(request, to: endpoint)
}
@@ -56,8 +61,13 @@ import Foundation
if input.json {
print(String(decoding: try WireJSON.encode(response), as: UTF8.self))
} else if response.ok {
- if [.helperStatus, .helperSetup, .helperRemove, .prepareUpdate, .prepareUninstall].contains(
- request.action)
+ if request.action == .lidPermissionStatus, let word = response.lidPermission {
+ print(word)
+ } else if [.lidPermissionInstall, .lidPermissionRemove].contains(request.action) {
+ print(response.message ?? "Closed-lid permission updated.")
+ } else if [.helperStatus, .helperSetup, .helperRemove, .prepareUpdate, .prepareUninstall]
+ .contains(
+ request.action)
{
print(response.message ?? "Helper operation completed.")
} else if let id = response.id {
@@ -261,6 +271,7 @@ import Foundation
dilated hold --ttl 10m [--label NAME] [--id ID | --lid]
dilated release --id ID
dilated run [--label NAME] [--lid] -- command arguments
+ dilated lid-permission status|install|remove [--json]
dilated helper status|setup|remove [--json]
dilated prepare-update|prepare-uninstall [--json]
dilated package-check
@@ -268,6 +279,10 @@ import Foundation
Starts allow display sleep unless --display-awake is supplied.
--override-low-power explicitly allows a new hold in Low Power Mode.
--lid requires the app's approved helper and its closed-lid safety limits.
+ lid-permission status prints notInstalled, needsRepair, blocked, ready, notAdmin or
+ allowedByOtherRule (another app's sudo rule already covers closed-lid).
+ lid-permission install and remove ask for your administrator password in the app.
+ With closed-lid permission turned on, the helper commands map to it and are deprecated.
helper setup opens macOS approval when needed. Move the app to Applications first.
helper remove verifies sleep-disable is off before unregistering the helper.
prepare-update removes the helper and quits while preserving only unexpired timers.
diff --git a/Sources/DilatedControl/LocalSocket.swift b/Sources/DilatedControl/LocalSocket.swift
index 1257a99..3ea2d07 100644
--- a/Sources/DilatedControl/LocalSocket.swift
+++ b/Sources/DilatedControl/LocalSocket.swift
@@ -201,6 +201,13 @@ public struct AppConnectionUnavailable: LocalizedError {
}
public enum LocalSocketClient {
+ /// Long enough for the admin password dialog; status keeps the short timeout.
+ public static let adminPromptSeconds = 300
+
+ public static func usesAdminPrompt(_ action: ControlAction) -> Bool {
+ [.lidPermissionInstall, .lidPermissionRemove, .helperSetup, .helperRemove].contains(action)
+ }
+
public static func send(_ request: ControlRequest, to endpoint: SocketEndpoint) throws
-> ControlResponse
{
@@ -210,8 +217,12 @@ public enum LocalSocketClient {
let powerOperation = [.stopAll, .release, .toggle].contains(request.action)
let helperOperation = [.helperSetup, .helperRemove, .prepareUpdate, .prepareUninstall].contains(
request.action)
+ let adminPrompt = Self.usesAdminPrompt(request.action)
configure(
- fd, receiveSeconds: helperOperation ? 60 : request.closedLid ? 20 : powerOperation ? 10 : 3)
+ fd,
+ receiveSeconds: adminPrompt
+ ? Self.adminPromptSeconds
+ : helperOperation ? 60 : request.closedLid ? 20 : powerOperation ? 10 : 3)
var addr = try address(for: endpoint)
let result = withUnsafePointer(to: &addr) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
diff --git a/Sources/DilatedControl/Protocol.swift b/Sources/DilatedControl/Protocol.swift
index 719cc96..3135193 100644
--- a/Sources/DilatedControl/Protocol.swift
+++ b/Sources/DilatedControl/Protocol.swift
@@ -4,6 +4,7 @@ import Foundation
public enum ControlAction: String, Codable, Sendable {
case status, start, lease, runner, renew, release, stopAll, toggle
case helperStatus, helperSetup, helperRemove
+ case lidPermissionStatus, lidPermissionInstall, lidPermissionRemove
case prepareUpdate, prepareUninstall
}
public struct ControlRequest: Codable, Sendable {
@@ -52,11 +53,12 @@ public struct ControlResponse: Codable, Sendable {
public var code: String?
public var helper: HelperState?
public var helperInstallation: String?
+ public var lidPermission: String?
public var appPID: Int32?
public init(
ok: Bool, id: UUID? = nil, snapshot: EngineSnapshot? = nil, message: String? = nil,
code: String? = nil, helper: HelperState? = nil, helperInstallation: String? = nil,
- appPID: Int32? = nil
+ appPID: Int32? = nil, lidPermission: String? = nil
) {
self.ok = ok
self.id = id
@@ -65,6 +67,7 @@ public struct ControlResponse: Codable, Sendable {
self.code = code
self.helper = helper
self.helperInstallation = helperInstallation
+ self.lidPermission = lidPermission
self.appPID = appPID
}
}
@@ -94,6 +97,8 @@ public enum ControlRouter {
switch request.action {
case .helperStatus, .helperSetup, .helperRemove, .prepareUpdate, .prepareUninstall:
throw InputError("Helper management requires the Dilated app.")
+ case .lidPermissionStatus, .lidPermissionInstall, .lidPermissionRemove:
+ throw InputError("Closed-lid permission requires the Dilated app.")
case .status: break
case .stopAll: engine.stopAll(now: now)
case .release:
@@ -165,6 +170,16 @@ public struct CLIInput {
request: .init(action: verb == "prepare-update" ? .prepareUpdate : .prepareUninstall),
json: arguments.count == 2)
}
+ if verb == "lid-permission" {
+ let actions: [String: ControlAction] = [
+ "status": .lidPermissionStatus, "install": .lidPermissionInstall,
+ "remove": .lidPermissionRemove,
+ ]
+ guard arguments.count >= 2, let action = actions[arguments[1]],
+ arguments.count == 2 || (arguments.count == 3 && arguments[2] == "--json")
+ else { throw InputError("Use dilated lid-permission status|install|remove [--json].") }
+ return CLIInput(request: .init(action: action), json: arguments.count == 3)
+ }
if verb == "helper" {
let actions: [String: ControlAction] = [
"status": .helperStatus, "setup": .helperSetup, "remove": .helperRemove,
diff --git a/Sources/DilatedCore/ClosedLidPermission.swift b/Sources/DilatedCore/ClosedLidPermission.swift
new file mode 100644
index 0000000..f8ba7c9
--- /dev/null
+++ b/Sources/DilatedCore/ClosedLidPermission.swift
@@ -0,0 +1,316 @@
+import Darwin
+import Foundation
+
+public enum ClosedLidPermissionState: Equatable, Sendable {
+ case notInstalled, needsRepair, blocked, ready, notAdmin
+ /// Our rule file is absent but another app's sudoers rule already allows both `pmset` commands.
+ case allowedByOtherRule
+
+ /// The word the CLI prints and the control socket carries.
+ public var word: String {
+ switch self {
+ case .notInstalled: "notInstalled"
+ case .needsRepair: "needsRepair"
+ case .blocked: "blocked"
+ case .ready: "ready"
+ case .notAdmin: "notAdmin"
+ case .allowedByOtherRule: "allowedByOtherRule"
+ }
+ }
+
+ /// Whether closed-lid can run: our own rule works, or another app's rule covers it.
+ public var isUsable: Bool { self == .ready || self == .allowedByOtherRule }
+}
+
+public struct ClosedLidFileInfo: Equatable, Sendable {
+ public var isRegularFile: Bool
+ public var owner: UInt32
+ public var group: UInt32
+ public var mode: UInt16
+ public var size: Int
+
+ public init(
+ isRegularFile: Bool, owner: UInt32, group: UInt32, mode: UInt16,
+ size: Int = ClosedLidPermission.ruleContent.count
+ ) {
+ self.isRegularFile = isRegularFile
+ self.owner = owner
+ self.group = group
+ self.mode = mode
+ self.size = size
+ }
+}
+
+public enum PrivilegedScriptOutcome: Equatable, Sendable {
+ case succeeded, cancelled
+ case failed(String)
+}
+
+public protocol PrivilegedScriptRunning: Sendable {
+ func run(script: String) async -> PrivilegedScriptOutcome
+}
+
+public enum ClosedLidPermissionOutcome: Equatable, Sendable {
+ case completed(ClosedLidPermissionState)
+ case cancelled
+ case failed(String, ClosedLidPermissionState)
+}
+
+/// Detects, installs and removes the sudoers rule that lets Dilated run the two `pmset` commands
+/// it needs without a root helper. Every side-effecting dependency is injected.
+public struct ClosedLidPermission: Sendable {
+ public static let rulePath = "/private/etc/sudoers.d/dilated"
+ static let stagingPath = "/private/etc/sudoers.d/.dilated-staging"
+ static let scratchDirectory = "/private/var/root"
+ static let ruleLines = [
+ "Cmnd_Alias DILATED_PMSET = /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1",
+ "%admin ALL=(root) NOPASSWD: DILATED_PMSET",
+ ]
+ public static let ruleContent = Data((ruleLines.joined(separator: "\n") + "\n").utf8)
+ /// One `sudo -n -l` probe per command the rule has to cover. Both must succeed.
+ static let probeArgumentSets = ["1", "0"].map {
+ ["-n", "-l", "/usr/bin/pmset", "-a", "disablesleep", $0]
+ }
+
+ static let installScript = [
+ "set -eu",
+ "umask 077",
+ "tmp=$(/usr/bin/mktemp \(scratchDirectory)/dilated-sudoers.XXXXXX)",
+ "stage=\(stagingPath)",
+ "trap '/bin/rm -f \"$tmp\" \"$stage\"' EXIT",
+ "/usr/bin/printf '%s\\n%s\\n' '\(ruleLines[0])' '\(ruleLines[1])' > \"$tmp\"",
+ "/usr/sbin/visudo -cf \"$tmp\"",
+ "/usr/bin/install -o root -g wheel -m 0440 \"$tmp\" \"$stage\"",
+ "/bin/mv -f \"$stage\" \(rulePath)",
+ "/usr/sbin/visudo -cf \(rulePath) || { /bin/rm -f \(rulePath); exit 1; }",
+ ].joined(separator: "; ")
+
+ static let removeScript = [
+ "set -eu",
+ "/bin/rm -f \(rulePath)",
+ "test ! -e \(rulePath)",
+ ].joined(separator: "; ")
+
+ static let restoreScript = "/usr/bin/pmset -a disablesleep 0"
+
+ /// The AppleScript that runs a fixed shell script as root. Only backslashes and double quotes
+ /// need escaping, and the scripts above are constants.
+ static func appleScriptSource(for script: String) -> String {
+ let escaped = script.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ return "do shell script \"\(escaped)\" with administrator privileges"
+ }
+
+ /// `osascript` reports the user dismissing the admin prompt as AppleScript error -128.
+ static func outcome(status: Int32, stderr: String, timedOut: Bool = false)
+ -> PrivilegedScriptOutcome
+ {
+ if timedOut { return .failed("The administrator prompt timed out.") }
+ if status == 0 { return .succeeded }
+ if stderr.contains("-128") || stderr.contains("User canceled") { return .cancelled }
+ let message = stderr.trimmingCharacters(in: .whitespacesAndNewlines)
+ return .failed(message.isEmpty ? "The administrator command failed." : message)
+ }
+
+ public struct Seams: Sendable {
+ public var stat: @Sendable (String) -> ClosedLidFileInfo?
+ public var readFile: @Sendable (String) -> Data?
+ public var probe: @Sendable () async -> Bool
+ public var isAdmin: @Sendable () -> Bool
+
+ public init(
+ stat: @escaping @Sendable (String) -> ClosedLidFileInfo?,
+ readFile: @escaping @Sendable (String) -> Data?,
+ probe: @escaping @Sendable () async -> Bool,
+ isAdmin: @escaping @Sendable () -> Bool
+ ) {
+ self.stat = stat
+ self.readFile = readFile
+ self.probe = probe
+ self.isAdmin = isAdmin
+ }
+
+ public static let live = Seams(
+ stat: ClosedLidPermission.liveStat, readFile: { FileManager.default.contents(atPath: $0) },
+ probe: ClosedLidPermission.liveProbe, isAdmin: ClosedLidPermission.currentUserIsAdmin)
+ }
+
+ private let seams: Seams
+ private let runner: any PrivilegedScriptRunning
+
+ public init(
+ seams: Seams = .live, runner: any PrivilegedScriptRunning = OsascriptAdminRunner()
+ ) {
+ self.seams = seams
+ self.runner = runner
+ }
+
+ public func state() async -> ClosedLidPermissionState {
+ guard seams.isAdmin() else { return .notAdmin }
+ guard let info = seams.stat(Self.rulePath) else {
+ return await seams.probe() ? .allowedByOtherRule : .notInstalled
+ }
+ // The file is 0440 root:wheel, so the app can't read it. Size stands in for the bytes, and
+ // they're compared only if the file happens to be readable.
+ guard info.isRegularFile, info.owner == 0, info.group == 0, info.mode == 0o440,
+ info.size == Self.ruleContent.count
+ else { return .needsRepair }
+ if let bytes = seams.readFile(Self.rulePath), bytes != Self.ruleContent {
+ return .needsRepair
+ }
+ return await seams.probe() ? .ready : .blocked
+ }
+
+ public func install() async -> ClosedLidPermissionOutcome {
+ guard seams.isAdmin() else {
+ return .failed("Your account isn't an administrator, so the rule can't cover it.", .notAdmin)
+ }
+ return await perform(Self.installScript)
+ }
+
+ public func remove() async -> ClosedLidPermissionOutcome {
+ await perform(Self.removeScript)
+ }
+
+ /// The password-backed fallback: turns sleep back on without the sudo rule.
+ public func restoreNormalSleep() async -> PrivilegedScriptOutcome {
+ await runner.run(script: Self.appleScriptSource(for: Self.restoreScript))
+ }
+
+ private func perform(_ script: String) async -> ClosedLidPermissionOutcome {
+ switch await runner.run(script: Self.appleScriptSource(for: script)) {
+ case .cancelled: return .cancelled
+ case .succeeded: return .completed(await state())
+ case .failed(let message): return .failed(message, await state())
+ }
+ }
+
+ static func liveStat(_ path: String) -> ClosedLidFileInfo? {
+ var info = stat()
+ guard lstat(path, &info) == 0 else {
+ if errno == ENOENT || errno == ENOTDIR { return nil }
+ return ClosedLidFileInfo(
+ isRegularFile: false, owner: UInt32.max, group: UInt32.max, mode: 0, size: 0)
+ }
+ return ClosedLidFileInfo(
+ isRegularFile: info.st_mode & S_IFMT == S_IFREG, owner: info.st_uid, group: info.st_gid,
+ mode: info.st_mode & 0o7777, size: Int(info.st_size))
+ }
+
+ static func liveProbe() async -> Bool {
+ for arguments in probeArgumentSets {
+ guard
+ let result = try? await BoundedProcess.run(
+ executable: URL(fileURLWithPath: SudoPowerRunner.sudoPath), arguments: arguments,
+ timeout: 3, environment: [:]),
+ !result.timedOut, result.status == 0
+ else { return false }
+ }
+ return true
+ }
+
+ static func currentUserIsAdmin() -> Bool {
+ guard let group = getgrnam("admin"), let user = getpwuid(geteuid()) else { return false }
+ let adminGroup = group.pointee.gr_gid
+ var count: Int32 = 64
+ var groups = [Int32](repeating: 0, count: Int(count))
+ let name = user.pointee.pw_name
+ let base = Int32(bitPattern: user.pointee.pw_gid)
+ var status = getgrouplist(name, base, &groups, &count)
+ if status == -1 {
+ groups = [Int32](repeating: 0, count: Int(count))
+ status = getgrouplist(name, base, &groups, &count)
+ }
+ guard status != -1 else { return false }
+ return groups.prefix(Int(count)).contains { UInt32(bitPattern: $0) == adminGroup }
+ }
+}
+
+/// Runs a fixed shell script as root through the standard macOS administrator prompt, using
+/// `osascript` in a child process so the app (and its control socket) stays responsive while the
+/// dialog is open. The 5 minute bound is separate from `BoundedProcess`'s 3 second cap.
+public struct OsascriptAdminRunner: PrivilegedScriptRunning {
+ public static let promptTimeout: TimeInterval = 300
+ private let executable: URL
+ private let timeout: TimeInterval
+
+ public init(
+ executable: URL = URL(fileURLWithPath: "/usr/bin/osascript"),
+ timeout: TimeInterval = OsascriptAdminRunner.promptTimeout
+ ) {
+ self.executable = executable
+ self.timeout = timeout
+ }
+
+ public func run(script source: String) async -> PrivilegedScriptOutcome {
+ await Self.execute(executable: executable, arguments: ["-e", source], timeout: timeout)
+ }
+
+ /// Runs the process on a dispatch thread, never the main actor or the cooperative pool.
+ static func execute(executable: URL, arguments: [String], timeout: TimeInterval) async
+ -> PrivilegedScriptOutcome
+ {
+ let process = Process()
+ process.executableURL = executable
+ process.arguments = arguments
+ process.environment = ["PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "LANG": "en_US.UTF-8"]
+ let errors = Pipe()
+ process.standardInput = FileHandle.nullDevice
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = errors
+ let state = ExecutionState()
+ return await withTaskCancellationHandler {
+ await withCheckedContinuation {
+ (continuation: CheckedContinuation) in
+ DispatchQueue.global(qos: .userInitiated).async {
+ do { try process.run() } catch {
+ continuation.resume(returning: .failed("The administrator command couldn't start."))
+ return
+ }
+ state.attach(process)
+ let watchdog = DispatchWorkItem {
+ state.markTimedOut()
+ state.terminate()
+ }
+ DispatchQueue.global().asyncAfter(deadline: .now() + timeout, execute: watchdog)
+ let data = errors.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ watchdog.cancel()
+ continuation.resume(
+ returning: ClosedLidPermission.outcome(
+ status: process.terminationStatus, stderr: String(decoding: data, as: UTF8.self),
+ timedOut: state.timedOut))
+ }
+ }
+ } onCancel: {
+ state.terminate()
+ }
+ }
+
+ private final class ExecutionState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var process: Process?
+ private var cancelled = false
+ private(set) var timedOutFlag = false
+
+ var timedOut: Bool { lock.withLock { timedOutFlag } }
+
+ func attach(_ process: Process) {
+ let stopNow = lock.withLock {
+ self.process = process
+ return cancelled
+ }
+ if stopNow, process.isRunning { process.terminate() }
+ }
+
+ func markTimedOut() { lock.withLock { timedOutFlag = true } }
+
+ func terminate() {
+ let held = lock.withLock {
+ cancelled = true
+ return process
+ }
+ if let held, held.isRunning { held.terminate() }
+ }
+ }
+}
diff --git a/Sources/DilatedCore/ClosedLidService.swift b/Sources/DilatedCore/ClosedLidService.swift
index e384829..a3486a0 100644
--- a/Sources/DilatedCore/ClosedLidService.swift
+++ b/Sources/DilatedCore/ClosedLidService.swift
@@ -13,6 +13,7 @@ public actor ClosedLidService {
private var startup: Task?
private var resetConfirmed = false
private var pendingSleep = false
+ private var permissionFailure = false
private var operation = UUID()
public init(
@@ -107,6 +108,10 @@ public actor ClosedLidService {
}
}
+ /// True while the service is stuck in `.failed` because sudo refused the power command, so a
+ /// caller can slow its retries without giving up.
+ public var isRetryingForPermission: Bool { phase == .failed && permissionFailure }
+
public func tick() async {
guard started else { return }
if let stopped = governor.reconcile(environment: environment(), now: clock()) {
@@ -189,12 +194,18 @@ public actor ClosedLidService {
}
phase = .off
message = nil
+ permissionFailure = false
} catch {
guard operation == current else { return }
readback = await power.readback()
guard operation == current else { return }
phase = .failed
message = error.localizedDescription
+ if case ClosedLidPowerError.permissionUnavailable = error {
+ permissionFailure = true
+ } else {
+ permissionFailure = false
+ }
}
}
diff --git a/Sources/DilatedCore/ClosedLidSession.swift b/Sources/DilatedCore/ClosedLidSession.swift
new file mode 100644
index 0000000..a870839
--- /dev/null
+++ b/Sources/DilatedCore/ClosedLidSession.swift
@@ -0,0 +1,308 @@
+import Darwin
+import Foundation
+
+/// Written before sleep is disabled and removed only after a confirmed revert, so a dead app
+/// always leaves evidence that this process (and not Amphetamine or the user) disabled sleep.
+public struct ClosedLidSessionMarker: Codable, Equatable, Sendable {
+ public var pid: Int32
+ public var processStartMicros: Int64
+ public var generation: UUID
+ public var enabledAt: Date
+
+ public init(pid: Int32, processStartMicros: Int64, generation: UUID, enabledAt: Date) {
+ self.pid = pid
+ self.processStartMicros = processStartMicros
+ self.generation = generation
+ self.enabledAt = enabledAt
+ }
+}
+
+public struct ClosedLidMarkerStore: Sendable {
+ public static let fileName = "closed-lid-session.json"
+ public static let lockFileName = "closed-lid-session.lock"
+ public let file: URL
+
+ public init(directory: URL) { file = directory.appendingPathComponent(Self.fileName) }
+ public init(file: URL) { self.file = file }
+
+ /// The advisory lock that serializes the app and the watchdog around the marker and the revert.
+ public var lockFile: URL {
+ file.deletingLastPathComponent().appendingPathComponent(Self.lockFileName)
+ }
+
+ /// Takes the lock without blocking a thread: retries `flock(LOCK_NB)` with a short sleep until
+ /// `timeout`, so callers on an actor never wait indefinitely.
+ public func acquireLock(
+ timeout: Duration = .seconds(5), retry: Duration = .milliseconds(25)
+ ) async throws -> ClosedLidFileLock {
+ try await ClosedLidFileLock.acquire(at: lockFile, timeout: timeout, retry: retry)
+ }
+
+ public func read() -> ClosedLidSessionMarker? {
+ guard let data = try? Data(contentsOf: file) else { return nil }
+ return try? Self.decoder.decode(ClosedLidSessionMarker.self, from: data)
+ }
+
+ /// Atomic: readers see the old marker or the new one, never a partial file.
+ public func write(_ marker: ClosedLidSessionMarker) throws {
+ try FileManager.default.createDirectory(
+ at: file.deletingLastPathComponent(), withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700])
+ try Self.encoder.encode(marker).write(to: file, options: .atomic)
+ }
+
+ /// Removes the marker only when it still belongs to `generation`.
+ public func clear(generation: UUID) {
+ guard read()?.generation == generation else { return }
+ try? FileManager.default.removeItem(at: file)
+ }
+
+ private static var encoder: JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.sortedKeys]
+ return encoder
+ }
+ private static var decoder: JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return decoder
+ }
+}
+
+public enum ClosedLidSessionError: Error, Sendable, LocalizedError {
+ case lockTimeout, startTimeUnreadable
+
+ public var errorDescription: String? {
+ switch self {
+ case .lockTimeout: "Closed-lid state is busy. Try again."
+ case .startTimeUnreadable:
+ "Dilated couldn't read its own start time, so it won't disable sleep."
+ }
+ }
+}
+
+/// An exclusive advisory `flock` on a file. Released by `release()` or when the object goes away.
+public final class ClosedLidFileLock: @unchecked Sendable {
+ private let guardLock = NSLock()
+ private var descriptor: Int32
+
+ private init(descriptor: Int32) { self.descriptor = descriptor }
+ deinit { release() }
+
+ public static func acquire(
+ at url: URL, timeout: Duration, retry: Duration
+ ) async throws -> ClosedLidFileLock {
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(), withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700])
+ let descriptor = open(url.path, O_CREAT | O_RDWR | O_CLOEXEC, 0o600)
+ guard descriptor >= 0 else { throw ClosedLidSessionError.lockTimeout }
+ let deadline = ContinuousClock.now + timeout
+ while true {
+ if flock(descriptor, LOCK_EX | LOCK_NB) == 0 {
+ return ClosedLidFileLock(descriptor: descriptor)
+ }
+ guard errno == EWOULDBLOCK || errno == EINTR, ContinuousClock.now < deadline else {
+ close(descriptor)
+ throw ClosedLidSessionError.lockTimeout
+ }
+ do { try await Task.sleep(for: retry) } catch {
+ close(descriptor)
+ throw error
+ }
+ }
+ }
+
+ public func release() {
+ let held = guardLock.withLock {
+ let held = descriptor
+ descriptor = -1
+ return held
+ }
+ if held >= 0 {
+ flock(held, LOCK_UN)
+ close(held)
+ }
+ }
+}
+
+public enum ProcessStartTime {
+ /// Kernel start time of a live process in microseconds, or nil when it's gone or a zombie.
+ /// A pid with a different start time is a different process.
+ public static func micros(pid: Int32) -> Int64? {
+ var info = kinfo_proc()
+ var size = MemoryLayout.stride
+ var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
+ guard sysctl(&mib, 4, &info, &size, nil, 0) == 0, size > 0,
+ info.kp_proc.p_pid == pid, info.kp_proc.p_stat != SZOMB
+ else { return nil }
+ let started = info.kp_proc.p_un.__p_starttime
+ return Int64(started.tv_sec) * 1_000_000 + Int64(started.tv_usec)
+ }
+}
+
+public struct ClosedLidWatchdogLaunch: Equatable, Sendable {
+ public var pid: Int32
+ public var marker: URL
+ public var generation: UUID
+
+ public init(pid: Int32, marker: URL, generation: UUID) {
+ self.pid = pid
+ self.marker = marker
+ self.generation = generation
+ }
+}
+
+/// Wraps the power driver for the local (sudo) path: marker before enable, marker removed only
+/// after a confirmed revert, a stale marker from a dead app reverted on first use, and one
+/// watchdog launch per session generation.
+public actor ClosedLidSessionGuard: ClosedLidPowerControlling {
+ public enum Recovery: Equatable, Sendable {
+ case notChecked
+ case clean
+ /// A dead app's marker was found and sleep was restored.
+ case reverted
+ /// Sleep is disabled with no marker of ours. Left alone.
+ case foreignSleepDisabled
+ /// The marker's app is still running (another Dilated on this state directory).
+ case activeElsewhere
+ /// A stale marker is still there because the revert failed. Retried on every power call.
+ case failed(String)
+ }
+
+ private let power: any ClosedLidPowerControlling
+ private let store: ClosedLidMarkerStore
+ private let startMicros: @Sendable (Int32) -> Int64?
+ private let pid: Int32
+ private let now: @Sendable () -> Date
+ private let launchWatchdog: @Sendable (ClosedLidWatchdogLaunch) throws -> Void
+ private var session: ClosedLidSessionMarker?
+ private var settled = false
+ private var recoveryError: (any Error)?
+ public private(set) var recovery = Recovery.notChecked
+
+ public init(
+ power: any ClosedLidPowerControlling, store: ClosedLidMarkerStore,
+ pid: Int32 = getpid(),
+ startMicros: @escaping @Sendable (Int32) -> Int64? = { ProcessStartTime.micros(pid: $0) },
+ now: @escaping @Sendable () -> Date = { Date() },
+ launchWatchdog: @escaping @Sendable (ClosedLidWatchdogLaunch) throws -> Void = { _ in }
+ ) {
+ self.power = power
+ self.store = store
+ self.pid = pid
+ self.startMicros = startMicros
+ self.now = now
+ self.launchWatchdog = launchWatchdog
+ }
+
+ public func readback() async -> ClosedLidReadback { await power.readback() }
+ public func sleep() async throws { try await power.sleep() }
+
+ public func setSleepDisabled(_ disabled: Bool) async throws -> ClosedLidReadback {
+ await recoverIfNeeded()
+ if let recoveryError { throw recoveryError }
+ return disabled ? try await enable() : try await disable()
+ }
+
+ private func enable() async throws -> ClosedLidReadback {
+ let lock = try await store.acquireLock()
+ defer { lock.release() }
+ var created: ClosedLidSessionMarker?
+ if session == nil {
+ // A zero start time would make the watchdog think the live app is already gone.
+ guard let start = startMicros(pid), start > 0 else {
+ throw ClosedLidSessionError.startTimeUnreadable
+ }
+ let marker = ClosedLidSessionMarker(
+ pid: pid, processStartMicros: start, generation: UUID(), enabledAt: now())
+ try store.write(marker)
+ session = marker
+ created = marker
+ do {
+ try launchWatchdog(
+ ClosedLidWatchdogLaunch(pid: pid, marker: store.file, generation: marker.generation))
+ } catch {
+ store.clear(generation: marker.generation)
+ session = nil
+ throw error
+ }
+ }
+ do {
+ return try await power.setSleepDisabled(true)
+ } catch {
+ // No confirmed enable: drop the session. Deleting the marker makes the watchdog exit.
+ if let created {
+ store.clear(generation: created.generation)
+ session = nil
+ }
+ throw error
+ }
+ }
+
+ private func disable() async throws -> ClosedLidReadback {
+ let lock = try await store.acquireLock()
+ defer { lock.release() }
+ if session == nil {
+ if recovery == .activeElsewhere { return await power.readback() }
+ if store.read() == nil {
+ let observed = await power.readback()
+ if observed.effectiveDisabled == true || observed.storedDisabled == true {
+ recovery = .foreignSleepDisabled
+ return observed
+ }
+ if recovery == .foreignSleepDisabled { recovery = .clean }
+ }
+ }
+ let observed = try await power.setSleepDisabled(false)
+ if observed.confirmedDisabled, let session {
+ store.clear(generation: session.generation)
+ self.session = nil
+ }
+ return observed
+ }
+
+ /// Called after the password-backed restore. Clears a failed revert once macOS confirms sleep
+ /// is back on; returns false (and changes nothing) when it isn't.
+ public func acknowledgeManualRestore() async -> Bool {
+ guard await power.readback().confirmedDisabled else { return false }
+ if let lock = try? await store.acquireLock() {
+ if let marker = store.read() { store.clear(generation: marker.generation) }
+ lock.release()
+ }
+ session = nil
+ recoveryError = nil
+ recovery = .reverted
+ settled = true
+ return true
+ }
+
+ private func recoverIfNeeded() async {
+ guard !settled, session == nil else { return }
+ recoveryError = nil
+ guard let marker = store.read() else {
+ recovery = .clean
+ settled = true
+ return
+ }
+ if startMicros(marker.pid) == marker.processStartMicros {
+ recovery = .activeElsewhere
+ return
+ }
+ do {
+ let lock = try await store.acquireLock()
+ defer { lock.release() }
+ // The marker may have been replaced or cleared while waiting for the lock.
+ guard store.read()?.generation == marker.generation else { return }
+ let observed = try await power.setSleepDisabled(false)
+ guard observed.confirmedDisabled else { throw ClosedLidPowerError.readbackMismatch }
+ store.clear(generation: marker.generation)
+ recovery = .reverted
+ settled = true
+ } catch {
+ recovery = .failed(error.localizedDescription)
+ recoveryError = error
+ }
+ }
+}
diff --git a/Sources/DilatedCore/ClosedLidWatchdog.swift b/Sources/DilatedCore/ClosedLidWatchdog.swift
new file mode 100644
index 0000000..51f16f1
--- /dev/null
+++ b/Sources/DilatedCore/ClosedLidWatchdog.swift
@@ -0,0 +1,287 @@
+import Darwin
+import Foundation
+
+/// Restores sleep after the app dies. Runs in a detached `dilated` process; this is the logic,
+/// with every dependency injected so it can be tested without sudo or a real crash.
+public struct ClosedLidWatchdog: Sendable {
+ public enum Outcome: Equatable, Sendable {
+ case reverted
+ /// The wait was cancelled while a revert was still failing. The marker stays for the relaunch revert.
+ case revertFailed
+ /// The session ended normally, or a newer one replaced it.
+ case markerGone
+ }
+
+ private enum Event: Sendable { case exited, markerGone }
+
+ /// First retry delay after a failed revert. It doubles up to `maxRevertBackoff`.
+ public static let initialRevertBackoff = Duration.seconds(5)
+ public static let maxRevertBackoff = Duration.seconds(300)
+
+ public static func revertBackoff(attempt: Int) -> Duration {
+ var delay = initialRevertBackoff
+ for _ in 0.. ClosedLidSessionMarker?
+ private let onExit: @Sendable (@escaping @Sendable () -> Void) -> @Sendable () -> Void
+ private let power: any ClosedLidPowerControlling
+ private let clearMarker: @Sendable () -> Void
+ private let acquireLock: @Sendable () async -> (@Sendable () -> Void)?
+ private let sleep: @Sendable (Duration) async throws -> Void
+
+ /// `onExit` registers a handler called once when the app exits (possibly at once, possibly
+ /// from any thread) and returns a cancel closure. `acquireLock` returns a release closure, or
+ /// nil when the lock couldn't be taken in time.
+ public init(
+ generation: UUID, pollInterval: Duration = .seconds(10),
+ readMarker: @escaping @Sendable () -> ClosedLidSessionMarker?,
+ onExit: @escaping @Sendable (@escaping @Sendable () -> Void) -> @Sendable () -> Void,
+ power: any ClosedLidPowerControlling,
+ clearMarker: @escaping @Sendable () -> Void,
+ acquireLock: @escaping @Sendable () async -> (@Sendable () -> Void)? = { {} },
+ sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
+ ) {
+ self.generation = generation
+ self.pollInterval = pollInterval
+ self.readMarker = readMarker
+ self.onExit = onExit
+ self.power = power
+ self.clearMarker = clearMarker
+ self.acquireLock = acquireLock
+ self.sleep = sleep
+ }
+
+ /// Ours means the same generation and a usable start time. A zero start time can't identify
+ /// the app, so it's never acted on.
+ private var markerIsOurs: Bool {
+ guard let marker = readMarker() else { return false }
+ return marker.generation == generation && marker.processStartMicros > 0
+ }
+
+ /// Waits as long as the marker is ours and the app is alive. There is no lifetime cap: an AC
+ /// session can run indefinitely, and the poll ends the watchdog once the marker goes away.
+ public func run() async -> Outcome {
+ guard markerIsOurs else { return .markerGone }
+ // Dispatch sources, not child Tasks or a task group: on macOS 26 the runtime aborts in
+ // swift_task_dealloc when a spawned task awaits here (CI crash reports, 2026-11-02).
+ let (stream, events) = AsyncStream.makeStream(of: Event.self)
+ let cancelExit = onExit { events.yield(.exited) }
+ let timer = DispatchSource.makeTimerSource(
+ queue: DispatchQueue(label: "dilated.closed-lid-watchdog.poll"))
+ timer.setEventHandler {
+ if !markerIsOurs { events.yield(.markerGone) }
+ }
+ let interval = pollInterval.components
+ let nanos = max(
+ Int(interval.seconds) * 1_000_000_000 + Int(interval.attoseconds / 1_000_000_000), 1_000_000)
+ timer.schedule(deadline: .now() + .nanoseconds(nanos), repeating: .nanoseconds(nanos))
+ timer.resume()
+ let event: Event? = await withTaskCancellationHandler {
+ var first: Event?
+ for await next in stream {
+ first = next
+ break
+ }
+ return first
+ } onCancel: {
+ events.finish()
+ }
+ timer.cancel()
+ cancelExit()
+ events.finish()
+ switch event {
+ case .markerGone, nil: return .markerGone
+ case .exited: return await revert()
+ }
+ }
+
+ /// Retries with backoff for as long as the marker is still ours. Each attempt holds the lock,
+ /// re-checks the marker, reverts, confirms the readback, then deletes the marker.
+ private func revert() async -> Outcome {
+ var attempt = 0
+ while true {
+ guard markerIsOurs else { return .markerGone }
+ if let release = await acquireLock() {
+ defer { release() }
+ guard markerIsOurs else { return .markerGone }
+ if let observed = try? await power.setSleepDisabled(false), observed.confirmedDisabled {
+ clearMarker()
+ return .reverted
+ }
+ }
+ do { try await sleep(Self.revertBackoff(attempt: attempt)) } catch { return .revertFailed }
+ attempt += 1
+ }
+ }
+}
+
+public enum ProcessExit {
+ /// Calls `handler` once when the process with this pid and start time has exited, from any
+ /// thread, and at once if it already has. The returned closure stops watching; it's safe to
+ /// call more than once and after the handler has fired.
+ public static func register(
+ pid: Int32, startMicros: Int64, handler: @escaping @Sendable () -> Void
+ ) -> @Sendable () -> Void {
+ guard ProcessStartTime.micros(pid: pid) == startMicros else {
+ handler()
+ return {}
+ }
+ let registration = Registration(handler: handler)
+ let source = DispatchSource.makeProcessSource(
+ identifier: pid, eventMask: .exit, queue: .global())
+ source.setEventHandler { registration.fire() }
+ registration.attach(source)
+ source.resume()
+ // The process may have exited between the first check and the source going live.
+ if ProcessStartTime.micros(pid: pid) != startMicros { registration.fire() }
+ return { registration.cancel() }
+ }
+
+ private final class Registration: @unchecked Sendable {
+ private let lock = NSLock()
+ private var handler: (@Sendable () -> Void)?
+ private var source: (any DispatchSourceProcess)?
+
+ init(handler: @escaping @Sendable () -> Void) { self.handler = handler }
+
+ /// Called before the source is resumed, so a later cancel always cancels a resumed source.
+ func attach(_ source: any DispatchSourceProcess) {
+ lock.withLock { self.source = source }
+ }
+
+ func fire() {
+ let handler = lock.withLock {
+ let held = self.handler
+ self.handler = nil
+ return held
+ }
+ handler?()
+ }
+
+ func cancel() {
+ let source = lock.withLock {
+ handler = nil
+ let held = self.source
+ self.source = nil
+ return held
+ }
+ source?.cancel()
+ }
+ }
+
+ /// Returns when the process with this pid and start time has exited, via kqueue
+ /// EVFILT_PROC NOTE_EXIT (a dispatch process source). Returns at once if it already has, and
+ /// when the task is cancelled.
+ public static func wait(pid: Int32, startMicros: Int64) async {
+ guard ProcessStartTime.micros(pid: pid) == startMicros else { return }
+ let waiter = ExitWaiter()
+ await withTaskCancellationHandler {
+ await withCheckedContinuation { (continuation: CheckedContinuation) in
+ waiter.begin(continuation)
+ let source = DispatchSource.makeProcessSource(
+ identifier: pid, eventMask: .exit, queue: .global())
+ source.setEventHandler { waiter.finish() }
+ waiter.attach(source)
+ source.resume()
+ // The process may have exited between the first check and the source going live.
+ if ProcessStartTime.micros(pid: pid) != startMicros { waiter.finish() }
+ }
+ } onCancel: {
+ waiter.finish()
+ }
+ }
+
+ private final class ExitWaiter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation?
+ private var source: (any DispatchSourceProcess)?
+ private var finished = false
+
+ func begin(_ continuation: CheckedContinuation) {
+ let resumeNow = lock.withLock {
+ if finished { return true }
+ self.continuation = continuation
+ return false
+ }
+ if resumeNow { continuation.resume() }
+ }
+
+ func attach(_ source: any DispatchSourceProcess) {
+ let cancelNow = lock.withLock {
+ if finished { return true }
+ self.source = source
+ return false
+ }
+ if cancelNow { source.cancel() }
+ }
+
+ func finish() {
+ let (continuation, source) = lock.withLock {
+ finished = true
+ let held = (self.continuation, self.source)
+ self.continuation = nil
+ self.source = nil
+ return held
+ }
+ source?.cancel()
+ continuation?.resume()
+ }
+ }
+}
+
+/// The hidden `dilated closed-lid-watchdog` subcommand. Not listed in help.
+public enum ClosedLidWatchdogCommand {
+ public static let name = "closed-lid-watchdog"
+
+ public static func parse(_ arguments: [String]) -> ClosedLidWatchdogLaunch? {
+ guard arguments.first == name else { return nil }
+ var values: [String: String] = [:]
+ var index = 1
+ while index + 1 < arguments.count {
+ values[arguments[index]] = arguments[index + 1]
+ index += 2
+ }
+ guard index == arguments.count,
+ let pid = values["--pid"].flatMap({ Int32($0) }), pid > 0,
+ let marker = values["--marker"], marker.hasPrefix("/"),
+ let generation = values["--generation"].flatMap({ UUID(uuidString: $0) })
+ else { return nil }
+ return ClosedLidWatchdogLaunch(
+ pid: pid, marker: URL(fileURLWithPath: marker), generation: generation)
+ }
+
+ public static func arguments(for launch: ClosedLidWatchdogLaunch) -> [String] {
+ [
+ name, "--pid", String(launch.pid), "--marker", launch.marker.path,
+ "--generation", launch.generation.uuidString,
+ ]
+ }
+
+ /// Runs against the real system. Exit status 0 unless sleep couldn't be restored.
+ public static func run(_ launch: ClosedLidWatchdogLaunch) async -> Int32 {
+ let store = ClosedLidMarkerStore(file: launch.marker)
+ guard let marker = store.read(), marker.generation == launch.generation,
+ marker.pid == launch.pid, marker.processStartMicros > 0
+ else { return 0 }
+ let watchdog = ClosedLidWatchdog(
+ generation: launch.generation,
+ readMarker: { store.read() },
+ onExit: { handler in
+ ProcessExit.register(
+ pid: marker.pid, startMicros: marker.processStartMicros, handler: handler)
+ },
+ power: SerialPowerDriver(runner: SudoPowerRunner()),
+ clearMarker: { store.clear(generation: launch.generation) },
+ acquireLock: {
+ guard let lock = try? await store.acquireLock() else { return nil }
+ return { lock.release() }
+ })
+ return await watchdog.run() == .revertFailed ? 1 : 0
+ }
+}
diff --git a/Sources/DilatedUI/AppModel.swift b/Sources/DilatedUI/AppModel.swift
index 365f61c..13ee845 100644
--- a/Sources/DilatedUI/AppModel.swift
+++ b/Sources/DilatedUI/AppModel.swift
@@ -200,6 +200,7 @@ public struct AppSettings: Codable, Equatable, Sendable {
@ObservationIgnored public var onClosedLidBatteryWarning: (() -> Bool)?
public let closedLid: ClosedLidController
public let helperInstallation: HelperInstallation
+ public let closedLidPermission: ClosedLidPermissionModel
public let updater: AppUpdater
@ObservationIgnored public let endpoint: SocketEndpoint
@ObservationIgnored private let store: StateStore
@@ -274,6 +275,7 @@ public struct AppSettings: Codable, Equatable, Sendable {
assertions: any AssertionControlling = NativeAssertions(),
closedLid: ClosedLidController? = nil,
helperInstallation: HelperInstallation? = nil,
+ closedLidPermission: ClosedLidPermissionModel? = nil,
updater: AppUpdater? = nil
) {
self.endpoint = endpoint
@@ -284,8 +286,15 @@ public struct AppSettings: Codable, Equatable, Sendable {
self.feedback = FeedbackController(
service: NativeFeedbackService(),
sounds: NativeFeedbackSounds(directory: endpoint.stateDirectory))
- self.closedLid = closedLid ?? ClosedLidController.configured()
+ self.closedLid =
+ closedLid ?? ClosedLidController.configured(stateDirectory: endpoint.stateDirectory)
self.helperInstallation = helperInstallation ?? HelperInstallation()
+ let lid = self.closedLid
+ self.closedLidPermission =
+ closedLidPermission
+ ?? ClosedLidPermissionModel(
+ recovery: { await lid.localTransport?.recovery() ?? .notChecked },
+ acknowledgeRestore: { await lid.localTransport?.acknowledgeRestore() ?? false })
self.updater = updater ?? AppUpdater()
var loadMessage: String?
var recoveryIssue: String?
@@ -796,7 +805,7 @@ public struct AppSettings: Codable, Equatable, Sendable {
if settingsRecoveryIssue != nil,
![
.status, .stopAll, .release, .helperStatus, .helperRemove, .prepareUpdate,
- .prepareUninstall,
+ .prepareUninstall, .lidPermissionStatus, .lidPermissionRemove,
]
.contains(request.action)
{
@@ -813,6 +822,10 @@ public struct AppSettings: Codable, Equatable, Sendable {
if [.helperStatus, .helperSetup, .helperRemove].contains(request.action) {
return await controlHelper(request.action)
}
+ if [.lidPermissionStatus, .lidPermissionInstall, .lidPermissionRemove].contains(request.action)
+ {
+ return await controlLidPermission(request.action)
+ }
guard !request.closedLid || [.start, .lease, .runner, .toggle].contains(request.action) else {
return ControlResponse(
ok: false, message: "Renewals can't enable closed-lid. Start a new hold or use the menu.",
@@ -853,7 +866,94 @@ public struct AppSettings: Codable, Equatable, Sendable {
}
return response
}
+ private func controlLidPermission(_ action: ControlAction, deprecationNote: String? = nil) async
+ -> ControlResponse
+ {
+ func reply(ok: Bool, message: String, code: String? = nil) -> ControlResponse {
+ ControlResponse(
+ ok: ok, snapshot: engine.snapshot,
+ message: [message, deprecationNote].compactMap { $0 }.joined(separator: " "), code: code,
+ helper: closedLid.state, lidPermission: closedLidPermission.state?.word)
+ }
+ guard closedLid.usesLocalTransport else {
+ return reply(
+ ok: false, message: "Closed-lid permission isn't turned on. This build uses the helper.",
+ code: "unavailable")
+ }
+ guard !closedLidPermission.busy || action == .lidPermissionStatus else {
+ return reply(
+ ok: false, message: "Closed-lid permission setup is still running.", code: "unavailable")
+ }
+ switch action {
+ case .lidPermissionInstall:
+ let result = await setUpClosedLidPermission()
+ switch result {
+ case .cancelled:
+ return reply(ok: true, message: "Cancelled. Nothing changed.", code: "cancelled")
+ case .failed:
+ return reply(
+ ok: false,
+ message: closedLidPermission.lastError ?? message ?? closedLidPermission.summary,
+ code: "permissionFailure")
+ case .done:
+ return reply(ok: true, message: closedLidPermission.summary)
+ }
+ case .lidPermissionRemove:
+ let result = await removeClosedLidPermission()
+ switch result {
+ case .cancelled:
+ return reply(ok: true, message: "Cancelled. Nothing changed.", code: "cancelled")
+ case .failed:
+ return reply(
+ ok: false,
+ message: closedLidPermission.lastError ?? message ?? closedLidPermission.summary,
+ code: "permissionFailure")
+ case .done:
+ return reply(ok: true, message: "Closed-lid is off and the permission has been removed.")
+ }
+ default:
+ await closedLidPermission.refresh()
+ return reply(ok: true, message: closedLidPermission.summary)
+ }
+ }
+ public func setUpClosedLidPermission() async -> ClosedLidPermissionModel.Result {
+ guard allowNewWork() else { return .failed }
+ let result = await closedLidPermission.install()
+ message = closedLidPermission.lastError
+ onChange?()
+ return result
+ }
+ public func removeClosedLidPermission() async -> ClosedLidPermissionModel.Result {
+ guard allowNewWork(allowSettingsRecovery: true) else { return .failed }
+ resetTriggerLidArming()
+ engine.clearClosedLidRequests()
+ commit()
+ do { try await synchronizeClosedLid() } catch {
+ message = error.localizedDescription
+ onChange?()
+ return .failed
+ }
+ let result = await closedLidPermission.remove()
+ message = closedLidPermission.lastError
+ onChange?()
+ return result
+ }
+ public func restoreNormalSleep() async {
+ await closedLidPermission.restoreNormalSleep()
+ message = closedLidPermission.lastError
+ onChange?()
+ }
private func controlHelper(_ action: ControlAction) async -> ControlResponse {
+ if closedLid.usesLocalTransport {
+ let note = "Helper commands are deprecated. Use dilated lid-permission."
+ let mapped: ControlAction =
+ switch action {
+ case .helperSetup: .lidPermissionInstall
+ case .helperRemove: .lidPermissionRemove
+ default: .lidPermissionStatus
+ }
+ return await controlLidPermission(mapped, deprecationNote: note)
+ }
helperInstallation.refreshStatus()
guard !helperInstallation.busy || action == .helperStatus else {
return ControlResponse(
@@ -1138,15 +1238,25 @@ public struct AppSettings: Codable, Equatable, Sendable {
"Closed-lid needs an AppleScript hold or exactly one active hold. Supply a hold ID in Shortcuts when several holds are active."
)
}
+ private func requireClosedLidSetup() async throws {
+ if closedLid.usesLocalTransport {
+ await closedLidPermission.refresh()
+ guard closedLidPermission.state?.isUsable == true else {
+ throw InputError("Set up closed-lid permission in Dilated first.")
+ }
+ return
+ }
+ helperInstallation.refreshStatus()
+ guard helperInstallation.status == .enabled else {
+ throw InputError("Set up and approve the closed-lid helper in Dilated first.")
+ }
+ }
func setAutomationClosedLid(id: UUID, enabled: Bool) async throws {
engine.updatePower(readPower())
commit()
if enabled {
if engine.automationPaused { throw WakeError.automationPaused }
- helperInstallation.refreshStatus()
- guard helperInstallation.status == .enabled else {
- throw InputError("Set up and approve the closed-lid helper in Dilated first.")
- }
+ try await requireClosedLidSetup()
}
try await setClosedLid(id: id, enabled: enabled, allowPrompt: false, requireAutomation: true)
}
@@ -1352,10 +1462,7 @@ public struct AppSettings: Codable, Equatable, Sendable {
else {
throw InputError("Enable this trigger and its closed-lid option first.")
}
- helperInstallation.refreshStatus()
- guard helperInstallation.status == .enabled else {
- throw InputError("Set up and approve the closed-lid helper in Dilated first.")
- }
+ try await requireClosedLidSetup()
armingClosedLidTrigger = id
let revision = triggerLidRevision
defer {
@@ -1369,7 +1476,10 @@ public struct AppSettings: Codable, Equatable, Sendable {
triggerRules.first(where: { $0.id == id }) == rule
else { throw CancellationError() }
guard closedLid.state?.ready == true else {
- throw InputError("The helper isn't ready. Check Closed Lid settings before arming a trigger.")
+ throw InputError(
+ closedLid.usesLocalTransport
+ ? "Closed-lid isn't ready. Check Closed Lid settings before arming a trigger."
+ : "The helper isn't ready. Check Closed Lid settings before arming a trigger.")
}
guard commit() else { throw InputError(message ?? "Couldn't save awake state.") }
armedClosedLidTriggers.insert(id)
diff --git a/Sources/DilatedUI/Automation/AutomationService.swift b/Sources/DilatedUI/Automation/AutomationService.swift
index 0baf92a..e3f529f 100644
--- a/Sources/DilatedUI/Automation/AutomationService.swift
+++ b/Sources/DilatedUI/Automation/AutomationService.swift
@@ -93,7 +93,7 @@ enum ScriptValue: Equatable, Sendable {
snapshot.holds.isEmpty ? "Sleeping normally" : "Keeping awake",
"Automation: \(snapshot.automationPaused ? "paused" : "running")",
"Closed-lid requested: \(snapshot.holds.contains { $0.closedLid == true } ? "yes" : "no")",
- "Helper state: \(helper)",
+ "\(model.closedLid.usesLocalTransport ? "Closed-lid permission" : "Helper") state: \(helper)",
] + holds).joined(separator: "\n")
}
public func setClosedLid(id: UUID, enabled: Bool) async throws {
@@ -138,11 +138,12 @@ enum ScriptValue: Equatable, Sendable {
guard !model.closedLid.busy, !model.closedLid.pendingEnable else {
throw InputError("Closed-lid is still changing. Wait for confirmation before querying it.")
}
+ let subject = model.closedLid.usesLocalTransport ? "permission" : "helper"
guard let helper = model.closedLid.state else {
- throw InputError("Closed-lid helper state is unknown.")
+ throw InputError("Closed-lid \(subject) state is unknown.")
}
guard helper.enabled || helper.readback.confirmedDisabled else {
- throw InputError("Closed-lid helper state is unknown.")
+ throw InputError("Closed-lid \(subject) state is unknown.")
}
return .boolean(helper.enabled)
case .remaining:
diff --git a/Sources/DilatedUI/ClosedLid/ClosedLidController.swift b/Sources/DilatedUI/ClosedLid/ClosedLidController.swift
index 94e34e9..aa4e78d 100644
--- a/Sources/DilatedUI/ClosedLid/ClosedLidController.swift
+++ b/Sources/DilatedUI/ClosedLid/ClosedLidController.swift
@@ -17,6 +17,7 @@ import Observation
@ObservationIgnored private var operation = UUID()
@ObservationIgnored private var operationFailure: (any Error)?
var usesLocalTransport: Bool { transport is LocalClosedLidTransport }
+ var localTransport: LocalClosedLidTransport? { transport as? LocalClosedLidTransport }
public var active: Bool { leaseID != nil && state?.enabled == true }
public var hasLease: Bool { leaseID != nil }
diff --git a/Sources/DilatedUI/ClosedLid/ClosedLidPermissionModel.swift b/Sources/DilatedUI/ClosedLid/ClosedLidPermissionModel.swift
new file mode 100644
index 0000000..ae5ffdb
--- /dev/null
+++ b/Sources/DilatedUI/ClosedLid/ClosedLidPermissionModel.swift
@@ -0,0 +1,163 @@
+import DilatedCore
+import Observation
+
+/// What Settings and the control socket show for closed-lid permission, and the actions behind
+/// the buttons. Every side effect goes through the injected `ClosedLidPermission` and callbacks.
+@MainActor @Observable public final class ClosedLidPermissionModel {
+ public private(set) var state: ClosedLidPermissionState?
+ public private(set) var busy = false
+ public private(set) var lastError: String?
+ public private(set) var recovery = ClosedLidSessionGuard.Recovery.notChecked
+ @ObservationIgnored private let permission: ClosedLidPermission
+ @ObservationIgnored private let readRecovery:
+ @MainActor () async -> ClosedLidSessionGuard.Recovery
+ @ObservationIgnored private let acknowledgeRestore: @MainActor () async -> Bool
+
+ public init(
+ permission: ClosedLidPermission = ClosedLidPermission(),
+ recovery: @escaping @MainActor () async -> ClosedLidSessionGuard.Recovery = { .notChecked },
+ acknowledgeRestore: @escaping @MainActor () async -> Bool = { false }
+ ) {
+ self.permission = permission
+ readRecovery = recovery
+ self.acknowledgeRestore = acknowledgeRestore
+ }
+
+ public enum Result: Equatable, Sendable { case done, cancelled, failed }
+
+ public func refresh() async {
+ state = await permission.state()
+ recovery = await readRecovery()
+ }
+
+ /// Cancelling the admin prompt is a no-op: nothing changes and no error shows.
+ @discardableResult public func install() async -> Result {
+ await run { await permission.install() }
+ }
+ @discardableResult public func remove() async -> Result {
+ await run { await permission.remove() }
+ }
+
+ /// Runs the admin-prompt `pmset -a disablesleep 0`.
+ @discardableResult public func restoreNormalSleep() async -> Result {
+ guard !busy else { return .failed }
+ busy = true
+ lastError = nil
+ defer { busy = false }
+ switch await permission.restoreNormalSleep() {
+ case .cancelled:
+ return .cancelled
+ case .failed(let message):
+ lastError = message
+ return .failed
+ case .succeeded:
+ guard await acknowledgeRestore() else {
+ lastError = "macOS hasn't confirmed normal sleep yet. Try again."
+ await refresh()
+ return .failed
+ }
+ await refresh()
+ return .done
+ }
+ }
+
+ private func run(_ action: () async -> ClosedLidPermissionOutcome) async -> Result {
+ guard !busy else { return .failed }
+ busy = true
+ lastError = nil
+ defer { busy = false }
+ switch await action() {
+ case .cancelled:
+ await refresh()
+ return .cancelled
+ case .completed(let result):
+ state = result
+ recovery = await readRecovery()
+ return .done
+ case .failed(let message, let result):
+ state = result
+ lastError = message
+ recovery = await readRecovery()
+ return .failed
+ }
+ }
+
+ public var title: String {
+ switch state {
+ case nil: "Checking closed-lid permission"
+ case .ready: "Closed-lid permission is set up"
+ case .notInstalled: "Closed-lid permission isn't set up"
+ case .needsRepair: "Closed-lid permission needs repair"
+ case .blocked: "macOS isn't honoring the permission"
+ case .notAdmin: "Closed-lid needs an administrator account"
+ case .allowedByOtherRule: "Another app's sudo rule allows closed-lid"
+ }
+ }
+
+ public var detail: String {
+ switch state {
+ case nil: "One moment."
+ case .ready:
+ "Dilated can turn sleep off for closed-lid holds. Regular awake holds never need it."
+ case .notInstalled:
+ "Set up closed-lid permission to keep working with the lid closed. You'll enter your password once. Regular awake holds work without it."
+ case .needsRepair:
+ "The permission file isn't what Dilated wrote. Repair it, or remove it."
+ case .blocked:
+ "The permission file is in place, but sudo isn't honoring the rule. Repair it, or check that /etc/sudoers includes /etc/sudoers.d."
+ case .notAdmin:
+ "Closed-lid permission only works for administrators. Switch to an administrator account to use it."
+ case .allowedByOtherRule:
+ "Another app's sudo rule already allows this, so closed-lid works now. Set Up adds Dilated's own rule so it doesn't depend on that app."
+ }
+ }
+
+ public var setUpTitle: String? {
+ switch state {
+ case .notInstalled, .allowedByOtherRule: "Set Up"
+ case .needsRepair, .blocked: "Repair"
+ default: nil
+ }
+ }
+
+ public var canRemove: Bool {
+ switch state {
+ case .ready, .needsRepair, .blocked: true
+ default: false
+ }
+ }
+
+ /// Plain words for the CLI and control socket.
+ public var summary: String {
+ switch state {
+ case .ready: "Closed-lid permission is set up."
+ case .notInstalled: "Closed-lid permission isn't set up."
+ case .needsRepair: "Closed-lid permission needs repair."
+ case .blocked: "Closed-lid permission is in place, but macOS isn't honoring it."
+ case .notAdmin: "Closed-lid needs an administrator account."
+ case .allowedByOtherRule: "Closed-lid works through another app's sudo rule."
+ case nil: "Closed-lid permission hasn't been checked."
+ }
+ }
+
+ public var recoveryTitle: String? {
+ switch recovery {
+ case .foreignSleepDisabled: "Another app has turned off sleep"
+ case .failed: "Dilated couldn't restore normal sleep"
+ default: nil
+ }
+ }
+
+ public var recoveryDetail: String? {
+ switch recovery {
+ case .foreignSleepDisabled: "Dilated left it alone because it didn't start it."
+ case .failed(let reason): reason
+ default: nil
+ }
+ }
+
+ public var canRestoreNormalSleep: Bool {
+ if case .failed = recovery { return true }
+ return false
+ }
+}
diff --git a/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift b/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift
index 2281f49..8c4ee2d 100644
--- a/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift
+++ b/Sources/DilatedUI/ClosedLid/ClosedLidSettings.swift
@@ -6,28 +6,33 @@ import SwiftUI
@Bindable var model: AppModel
var body: some View {
VStack(alignment: .leading, spacing: 18) {
- SettingsGroup(eyebrow: "HELPER") {
- SettingsRow(
- title: model.closedLid.active ? "Closed-lid armed" : model.helperInstallation.statusText,
- detail: model.helperInstallation.lastError ?? model.closedLid.lastError
- ?? model.closedLid.state?.message
- ?? "Approve the signed background helper in Login Items. Regular awake holds work without it."
- ) {
- Button(model.helperInstallation.buttonTitle) { Task { await model.setupHelper() } }
- .disabled(model.helperInstallation.busy || model.closedLid.busy)
- }
- SettingsRow(title: "Check helper status") {
- Button("Refresh") { model.refreshHelperStatus() }
- .disabled(model.helperInstallation.busy || model.closedLid.busy)
- }
- if let state = model.closedLid.state {
- SettingsRow(title: "Power setting", detail: state.stopReason?.localizedDescription) {
- Text(
- state.readback.confirmedDisabled
- ? "Off, confirmed" : state.enabled ? "On, confirmed" : "Needs attention")
+ if model.closedLid.usesLocalTransport {
+ permissionGroup
+ } else {
+ SettingsGroup(eyebrow: "HELPER") {
+ SettingsRow(
+ title: model.closedLid.active
+ ? "Closed-lid armed" : model.helperInstallation.statusText,
+ detail: model.helperInstallation.lastError ?? model.closedLid.lastError
+ ?? model.closedLid.state?.message
+ ?? "Approve the signed background helper in Login Items. Regular awake holds work without it."
+ ) {
+ Button(model.helperInstallation.buttonTitle) { Task { await model.setupHelper() } }
+ .disabled(model.helperInstallation.busy || model.closedLid.busy)
}
- SettingsRow(title: "Lid") {
- Text(state.environment.lidClosed.map { $0 ? "Closed" : "Open" } ?? "Unavailable")
+ SettingsRow(title: "Check helper status") {
+ Button("Refresh") { model.refreshHelperStatus() }
+ .disabled(model.helperInstallation.busy || model.closedLid.busy)
+ }
+ if let state = model.closedLid.state {
+ SettingsRow(title: "Power setting", detail: state.stopReason?.localizedDescription) {
+ Text(
+ state.readback.confirmedDisabled
+ ? "Off, confirmed" : state.enabled ? "On, confirmed" : "Needs attention")
+ }
+ SettingsRow(title: "Lid") {
+ Text(state.environment.lidClosed.map { $0 ? "Closed" : "Open" } ?? "Unavailable")
+ }
}
}
}
@@ -93,14 +98,17 @@ import SwiftUI
}
)
).labelsHidden().accessibilityHint("Keep working with the lid closed")
- .disabled(model.closedLid.busy || model.helperInstallation.busy)
+ .disabled(
+ model.closedLid.busy || model.helperInstallation.busy
+ || model.closedLidPermission.busy)
}
}
}
}
SettingsNote(
- text:
- "Keep your Mac ventilated. Don't put it in a bag while it works. The helper checks battery, temperature and its 90-second app lease independently. A crash, reboot or safety stop requires enabling closed-lid again."
+ text: model.closedLid.usesLocalTransport
+ ? "Keep your Mac ventilated. Don't put it in a bag while it works. Dilated checks battery, temperature and its 90-second lease on its own. If Dilated quits or crashes, it restores normal sleep the next time it opens."
+ : "Keep your Mac ventilated. Don't put it in a bag while it works. The helper checks battery, temperature and its 90-second app lease independently. A crash, reboot or safety stop requires enabling closed-lid again."
)
SettingsGroup(eyebrow: "AUTOMATION") {
SettingsRow(
@@ -114,10 +122,55 @@ import SwiftUI
)
}
}
- if model.helperInstallation.status == .enabled {
+ if !model.closedLid.usesLocalTransport, model.helperInstallation.status == .enabled {
Button("Turn off and remove helper") { Task { await model.removeHelper() } }
.disabled(model.closedLid.busy || model.helperInstallation.busy)
}
}
}
+
+ @ViewBuilder private var permissionGroup: some View {
+ let permission = model.closedLidPermission
+ SettingsGroup(eyebrow: "PERMISSION") {
+ SettingsRow(
+ title: model.closedLid.active ? "Closed-lid armed" : permission.title,
+ detail: permission.lastError ?? model.closedLid.lastError ?? model.closedLid.state?.message
+ ?? permission.detail
+ ) {
+ HStack {
+ if let title = permission.setUpTitle {
+ Button(title) { Task { await model.setUpClosedLidPermission() } }
+ .disabled(permission.busy || model.closedLid.busy)
+ }
+ if permission.canRemove {
+ Button("Remove") { Task { await model.removeClosedLidPermission() } }
+ .disabled(permission.busy || model.closedLid.busy)
+ }
+ }
+ }
+ SettingsRow(title: "Check permission") {
+ Button("Refresh") { Task { await permission.refresh() } }
+ .disabled(permission.busy || model.closedLid.busy)
+ }
+ if let title = permission.recoveryTitle {
+ SettingsRow(title: title, detail: permission.recoveryDetail) {
+ if permission.canRestoreNormalSleep {
+ Button("Restore normal sleep") { Task { await model.restoreNormalSleep() } }
+ .disabled(permission.busy || model.closedLid.busy)
+ }
+ }
+ }
+ if let state = model.closedLid.state {
+ SettingsRow(title: "Power setting", detail: state.stopReason?.localizedDescription) {
+ Text(
+ state.readback.confirmedDisabled
+ ? "Off, confirmed" : state.enabled ? "On, confirmed" : "Needs attention")
+ }
+ SettingsRow(title: "Lid") {
+ Text(state.environment.lidClosed.map { $0 ? "Closed" : "Open" } ?? "Unavailable")
+ }
+ }
+ }
+ .task { await permission.refresh() }
+ }
}
diff --git a/Sources/DilatedUI/ClosedLid/ClosedLidWatchdogSpawner.swift b/Sources/DilatedUI/ClosedLid/ClosedLidWatchdogSpawner.swift
new file mode 100644
index 0000000..6965c0c
--- /dev/null
+++ b/Sources/DilatedUI/ClosedLid/ClosedLidWatchdogSpawner.swift
@@ -0,0 +1,91 @@
+import Darwin
+import DilatedCore
+import Foundation
+
+struct WatchdogSpawnError: LocalizedError {
+ let detail: String
+ var errorDescription: String? { detail }
+}
+
+/// Starts the bundled `dilated` CLI as a watchdog in its own session, so it outlives the app
+/// when the app crashes or is killed.
+enum ClosedLidWatchdogSpawner {
+ /// The watchdog this process started last. A newer generation replaces it: the old one is
+ /// stopped here instead of lingering until its next marker poll.
+ final class Current: @unchecked Sendable {
+ private let lock = NSLock()
+ private var pid: pid_t?
+
+ /// Records the new child and stops the one it replaces. The signal goes out under the lock,
+ /// which `reaped` also takes, so it can't reach a recycled pid.
+ func replace(with child: pid_t) {
+ lock.withLock {
+ if let old = pid { kill(old, SIGTERM) }
+ pid = child
+ }
+ }
+
+ /// Called once the child is reaped, so a recycled pid is never signalled.
+ func reaped(_ child: pid_t) {
+ lock.withLock { if pid == child { pid = nil } }
+ }
+
+ var running: pid_t? { lock.withLock { pid } }
+ }
+
+ static let shared = Current()
+
+ static func launcher(
+ executable: URL = Bundle.main.bundleURL.appendingPathComponent("Contents/MacOS/dilated"),
+ current: Current = shared
+ ) -> @Sendable (ClosedLidWatchdogLaunch) throws -> Void {
+ { launch in try spawn(launch, executable: executable, current: current) }
+ }
+
+ @discardableResult
+ static func spawn(
+ _ launch: ClosedLidWatchdogLaunch, executable: URL, current: Current = shared
+ ) throws -> pid_t {
+ guard FileManager.default.isExecutableFile(atPath: executable.path) else {
+ throw WatchdogSpawnError(
+ detail: "The crash-recovery watchdog isn't available in this copy of Dilated.")
+ }
+ var attributes: posix_spawnattr_t?
+ posix_spawnattr_init(&attributes)
+ defer { posix_spawnattr_destroy(&attributes) }
+ // Default signal dispositions and an empty mask, so SIGTERM stops the watchdog even if the
+ // app was started with it ignored or blocked.
+ var defaults = sigset_t()
+ sigfillset(&defaults)
+ var mask = sigset_t()
+ sigemptyset(&mask)
+ posix_spawnattr_setsigdefault(&attributes, &defaults)
+ posix_spawnattr_setsigmask(&attributes, &mask)
+ posix_spawnattr_setflags(
+ &attributes, Int16(POSIX_SPAWN_SETSID | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK))
+ var actions: posix_spawn_file_actions_t?
+ posix_spawn_file_actions_init(&actions)
+ defer { posix_spawn_file_actions_destroy(&actions) }
+ posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
+ posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0)
+ posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0)
+ let arguments = [executable.path] + ClosedLidWatchdogCommand.arguments(for: launch)
+ var argv: [UnsafeMutablePointer?] = arguments.map { strdup($0) } + [nil]
+ defer { for pointer in argv { free(pointer) } }
+ var child: pid_t = 0
+ let status = posix_spawn(&child, executable.path, &actions, &attributes, &argv, [nil])
+ guard status == 0 else {
+ throw WatchdogSpawnError(detail: "Couldn't start the crash-recovery watchdog (\(status)).")
+ }
+ // At most one watchdog per app: the replaced generation's process is stopped. It only
+ // reverts when the app dies, and the app is alive.
+ current.replace(with: child)
+ // The child is detached from our session but still ours to reap when it exits early.
+ DispatchQueue.global().async {
+ var ignored: Int32 = 0
+ waitpid(child, &ignored, 0)
+ current.reaped(child)
+ }
+ return child
+ }
+}
diff --git a/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift b/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift
index 5b85393..05049f5 100644
--- a/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift
+++ b/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift
@@ -1,3 +1,4 @@
+import DilatedControl
import DilatedCore
import Foundation
@@ -12,12 +13,17 @@ import Foundation
private let service: ClosedLidService
private let owner = UUID()
private let ticker: TickLoop
+ private let backoff: RetryBackoff
+ private let sessionGuard: ClosedLidSessionGuard?
public init(
+ sessionGuard: ClosedLidSessionGuard? = nil,
power: any ClosedLidPowerControlling = SerialPowerDriver(runner: SudoPowerRunner()),
environment: @escaping @Sendable () -> ClosedLidEnvironment = NativeClosedLidPower.environment,
clock: (@Sendable () -> TimeInterval)? = nil,
- tickInterval: Duration = .seconds(1)
+ tickInterval: Duration = .seconds(1),
+ backoff: RetryBackoff = RetryBackoff(),
+ sleep: @escaping @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) }
) {
let origin = ContinuousClock.now
let resolved: @Sendable () -> TimeInterval =
@@ -29,14 +35,28 @@ import Foundation
power: power, environment: environment, clock: resolved,
buildIdentity: Self.buildIdentity)
self.service = service
- ticker = TickLoop(service: service, interval: tickInterval)
+ self.backoff = backoff
+ self.sessionGuard = sessionGuard
+ ticker = TickLoop(service: service, interval: tickInterval, backoff: backoff, sleep: sleep)
}
public func send(_ request: HelperRequest) async throws -> HelperReply {
+ backoff.reset()
await service.start()
return await service.handle(request, owner: owner)
}
+ /// What the launch-time check found. Slice 4's UI reads this to show a failed revert or a
+ /// sleep-disable that something else owns.
+ public func recovery() async -> ClosedLidSessionGuard.Recovery {
+ await sessionGuard?.recovery ?? .notChecked
+ }
+
+ /// Tells the session guard that sleep was restored by hand. False when macOS doesn't confirm it.
+ public func acknowledgeRestore() async -> Bool {
+ await sessionGuard?.acknowledgeManualRestore() ?? false
+ }
+
public func invalidate() {
let service = service
let owner = owner
@@ -44,15 +64,49 @@ import Foundation
}
}
+/// Retry delay while sudo keeps refusing: doubles from `initial` up to `cap`, and starts over on
+/// `reset()`. The service keeps retrying and stays failed; only the cadence slows.
+public final class RetryBackoff: @unchecked Sendable {
+ private let initial: Duration
+ private let cap: Duration
+ private let lock = NSLock()
+ private var current: Duration?
+
+ public init(initial: Duration = .seconds(1), cap: Duration = .seconds(30)) {
+ self.initial = initial
+ self.cap = cap
+ }
+
+ public func next() -> Duration {
+ lock.withLock {
+ let delay = current.map { min($0 * 2, cap) } ?? min(initial, cap)
+ current = delay
+ return delay
+ }
+ }
+
+ public func reset() { lock.withLock { current = nil } }
+}
+
private final class TickLoop: Sendable {
private let task: Task
- init(service: ClosedLidService, interval: Duration) {
+ init(
+ service: ClosedLidService, interval: Duration, backoff: RetryBackoff,
+ sleep: @escaping @Sendable (Duration) async throws -> Void
+ ) {
task = Task.detached {
await service.start()
+ var delay = interval
while !Task.isCancelled {
- do { try await Task.sleep(for: interval) } catch { break }
+ do { try await sleep(delay) } catch { break }
await service.tick()
+ if await service.isRetryingForPermission {
+ delay = backoff.next()
+ } else {
+ backoff.reset()
+ delay = interval
+ }
}
}
}
@@ -63,12 +117,19 @@ private final class TickLoop: Sendable {
extension ClosedLidController {
/// Picks the transport from the `DilatedLocalClosedLidTransport` defaults flag. Off by default,
/// which keeps the privileged helper.
- public static func configured(defaults: UserDefaults = .standard) -> ClosedLidController {
+ public static func configured(
+ defaults: UserDefaults = .standard,
+ stateDirectory: URL = SocketEndpoint.current.stateDirectory
+ ) -> ClosedLidController {
guard defaults.bool(forKey: LocalClosedLidTransport.defaultsKey) else {
return ClosedLidController()
}
+ let session = ClosedLidSessionGuard(
+ power: SerialPowerDriver(runner: SudoPowerRunner()),
+ store: ClosedLidMarkerStore(directory: stateDirectory),
+ launchWatchdog: ClosedLidWatchdogSpawner.launcher())
return ClosedLidController(
- transport: LocalClosedLidTransport(),
+ transport: LocalClosedLidTransport(sessionGuard: session, power: session),
expectedBuild: { LocalClosedLidTransport.buildIdentity })
}
}
diff --git a/Tests/DilatedControlTests/ControlTests.swift b/Tests/DilatedControlTests/ControlTests.swift
index 29db804..e5f7c61 100644
--- a/Tests/DilatedControlTests/ControlTests.swift
+++ b/Tests/DilatedControlTests/ControlTests.swift
@@ -5,6 +5,14 @@ import Testing
@testable import DilatedControl
struct ControlTests {
+ @Test func onlyTheAdminPromptCommandsGetTheLongReadTimeout() {
+ #expect(LocalSocketClient.adminPromptSeconds == 300)
+ #expect(LocalSocketClient.usesAdminPrompt(.lidPermissionInstall))
+ #expect(LocalSocketClient.usesAdminPrompt(.lidPermissionRemove))
+ #expect(!LocalSocketClient.usesAdminPrompt(.lidPermissionStatus))
+ #expect(!LocalSocketClient.usesAdminPrompt(.status))
+ }
+
@Test func packageCommandsHaveExplicitIntentAndRejectHoldOptions() {
#expect((try? CLIInput.parse(["prepare-update", "--json"]))?.request?.action == .prepareUpdate)
#expect((try? CLIInput.parse(["prepare-uninstall"]))?.request?.action == .prepareUninstall)
@@ -35,6 +43,51 @@ struct ControlTests {
}
}
+ @Test func lidPermissionCommandsAreExplicitAppOperations() throws {
+ for (command, action) in [
+ ("status", ControlAction.lidPermissionStatus), ("install", .lidPermissionInstall),
+ ("remove", .lidPermissionRemove),
+ ] {
+ let input = try CLIInput.parse(["lid-permission", command, "--json"])
+ #expect(input.request?.action == action)
+ #expect(input.json)
+ #expect(try CLIInput.parse(["lid-permission", command]).json == false)
+ var engine = WakeEngine()
+ #expect(!ControlRouter.handle(input.request!, engine: &engine).ok)
+ #expect(engine.holds.isEmpty)
+ }
+ for arguments in [
+ ["lid-permission"], ["lid-permission", "setup"], ["lid-permission", "remove", "--lid"],
+ ["lid-permission", "status", "--json", "--json"],
+ ] {
+ #expect(throws: (any Error).self) { try CLIInput.parse(arguments) }
+ }
+ }
+
+ @Test func lidPermissionActionsAndStateRoundTripOnTheWire() throws {
+ for action in [
+ ControlAction.lidPermissionStatus, .lidPermissionInstall, .lidPermissionRemove,
+ ] {
+ let data = try WireJSON.encode(ControlRequest(action: action))
+ #expect(try WireJSON.decode(ControlRequest.self, from: data).action == action)
+ }
+ let response = ControlResponse(ok: true, message: "Ready.", lidPermission: "ready")
+ let decoded = try WireJSON.decode(
+ ControlResponse.self, from: try WireJSON.encode(response))
+ #expect(decoded.lidPermission == "ready")
+ let old = try WireJSON.decode(ControlResponse.self, from: Data(#"{"ok":true}"#.utf8))
+ #expect(old.lidPermission == nil)
+ #expect(
+ Set(
+ [
+ ClosedLidPermissionState.notInstalled, .needsRepair, .blocked, .ready, .notAdmin,
+ .allowedByOtherRule,
+ ].map(\.word))
+ == [
+ "notInstalled", "needsRepair", "blocked", "ready", "notAdmin", "allowedByOtherRule",
+ ])
+ }
+
@Test func minimalWireRequestsUseSafeDefaults() throws {
let request = try WireJSON.decode(ControlRequest.self, from: Data(#"{"action":"status"}"#.utf8))
#expect(request.action == .status)
diff --git a/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift b/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift
new file mode 100644
index 0000000..f0b6e55
--- /dev/null
+++ b/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift
@@ -0,0 +1,238 @@
+import Foundation
+import Testing
+
+@testable import DilatedCore
+
+struct ClosedLidPermissionTests {
+ private static let good = ClosedLidFileInfo(
+ isRegularFile: true, owner: 0, group: 0, mode: 0o440)
+
+ private func permission(
+ info: ClosedLidFileInfo? = good, content: Data? = nil,
+ probe: Bool = true, admin: Bool = true,
+ runner: any PrivilegedScriptRunning = RecordingRunner(outcome: .succeeded)
+ ) -> ClosedLidPermission {
+ ClosedLidPermission(
+ seams: .init(
+ stat: { _ in info }, readFile: { _ in content }, probe: { probe }, isAdmin: { admin }),
+ runner: runner)
+ }
+
+ @Test func theRuleContentIsFixed() {
+ #expect(
+ String(decoding: ClosedLidPermission.ruleContent, as: UTF8.self)
+ == """
+ Cmnd_Alias DILATED_PMSET = /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+ %admin ALL=(root) NOPASSWD: DILATED_PMSET
+
+ """)
+ #expect(ClosedLidPermission.rulePath == "/private/etc/sudoers.d/dilated")
+ }
+
+ @Test func detectsEveryState() async {
+ #expect(await permission(info: nil, probe: false).state() == .notInstalled)
+ #expect(await permission(info: nil).state() == .allowedByOtherRule)
+ #expect(ClosedLidPermissionState.allowedByOtherRule.isUsable)
+ #expect(ClosedLidPermissionState.ready.isUsable)
+ #expect(!ClosedLidPermissionState.notInstalled.isUsable)
+ #expect(!ClosedLidPermissionState.blocked.isUsable)
+ #expect(await permission(content: Data("x\n".utf8)).state() == .needsRepair)
+ #expect(await permission(content: nil).state() == .ready)
+ var info = Self.good
+ info.owner = 501
+ #expect(await permission(info: info).state() == .needsRepair)
+ info = Self.good
+ info.group = 20
+ #expect(await permission(info: info).state() == .needsRepair)
+ info = Self.good
+ info.mode = 0o444
+ #expect(await permission(info: info).state() == .needsRepair)
+ info = Self.good
+ info.mode = 0o644
+ #expect(await permission(info: info).state() == .needsRepair)
+ info = Self.good
+ info.size = ClosedLidPermission.ruleContent.count + 1
+ #expect(await permission(info: info).state() == .needsRepair)
+ info = Self.good
+ info.isRegularFile = false
+ #expect(await permission(info: info).state() == .needsRepair)
+ #expect(await permission(probe: false).state() == .blocked)
+ #expect(await permission().state() == .ready)
+ #expect(await permission(admin: false).state() == .notAdmin)
+ #expect(await permission(info: nil, admin: false).state() == .notAdmin)
+ #expect(ClosedLidPermissionState.allowedByOtherRule.word == "allowedByOtherRule")
+ }
+
+ @Test func installAndRemoveScriptsAreConstantAndCheckSudoers() {
+ let install = ClosedLidPermission.installScript
+ #expect(install.contains("/usr/bin/mktemp /private/var/root/"))
+ #expect(install.contains("/usr/bin/printf"))
+ #expect(install.contains("/usr/sbin/visudo -cf \"$tmp\""))
+ #expect(install.contains("/usr/bin/install -o root -g wheel -m 0440"))
+ #expect(install.contains("/bin/mv -f \"$stage\" /private/etc/sudoers.d/dilated"))
+ #expect(
+ install.contains(
+ "/usr/sbin/visudo -cf /private/etc/sudoers.d/dilated || { /bin/rm -f /private/etc/sudoers.d/dilated; exit 1; }"
+ ))
+ #expect(install.contains("DILATED_PMSET"))
+ let remove = ClosedLidPermission.removeScript
+ #expect(remove.contains("/bin/rm -f /private/etc/sudoers.d/dilated"))
+ #expect(remove.hasSuffix("test ! -e /private/etc/sudoers.d/dilated"))
+ #expect(!remove.contains("visudo"))
+ let source = ClosedLidPermission.appleScriptSource(for: remove)
+ #expect(source.hasPrefix("do shell script \""))
+ #expect(source.hasSuffix("\" with administrator privileges"))
+ let quoted = ClosedLidPermission.appleScriptSource(for: install)
+ #expect(quoted.contains("\\\"$tmp\\\""))
+ #expect(quoted.contains("\\\\n"))
+ }
+
+ @Test func scriptsNeverGateOnTheGlobalSudoersCheck() {
+ // Another app's bad sudoers file (Amphetamine's 0644) makes a global `visudo -c` exit 1.
+ // Every visudo call must name a single file with -f.
+ for script in [ClosedLidPermission.installScript, ClosedLidPermission.removeScript] {
+ for part in script.components(separatedBy: "; ") where part.contains("visudo") {
+ #expect(part.contains("visudo -cf "))
+ }
+ #expect(!script.contains("visudo -c ||"))
+ #expect(!script.hasSuffix("visudo -c"))
+ }
+ }
+
+ @Test func ourInstallStaysReadyWhenAnotherSudoersFileIsBad() async {
+ // The fake world has our file good and another file bad; detection and scripts only see ours.
+ let runner = RecordingRunner(outcome: .succeeded)
+ let outcome = await permission(runner: runner).install()
+ #expect(outcome == .completed(.ready))
+ let script = ClosedLidPermission.installScript
+ #expect(!script.contains("amphetamine"))
+ #expect(!script.contains("sudoers.d/*"))
+ }
+
+ @Test func installRunsTheFixedScriptThenRedetects() async {
+ let runner = RecordingRunner(outcome: .succeeded)
+ let outcome = await permission(runner: runner).install()
+ #expect(outcome == .completed(.ready))
+ #expect(
+ await runner.scripts
+ == [ClosedLidPermission.appleScriptSource(for: ClosedLidPermission.installScript)])
+ }
+
+ @Test func installReportsTheResultingStateEvenWhenItIsStillBlocked() async {
+ let outcome = await permission(probe: false).install()
+ #expect(outcome == .completed(.blocked))
+ }
+
+ @Test func removeRunsTheFixedScriptThenRedetects() async {
+ let runner = RecordingRunner(outcome: .succeeded)
+ let outcome = await permission(info: nil, probe: false, runner: runner).remove()
+ #expect(outcome == .completed(.notInstalled))
+ #expect(
+ await runner.scripts
+ == [ClosedLidPermission.appleScriptSource(for: ClosedLidPermission.removeScript)])
+ }
+
+ @Test func cancellingTheAdminPromptIsNotAnError() async {
+ let runner = RecordingRunner(outcome: .cancelled)
+ #expect(await permission(runner: runner).install() == .cancelled)
+ #expect(await permission(runner: runner).remove() == .cancelled)
+ #expect(
+ ClosedLidPermission.outcome(
+ status: 1, stderr: "execution error: User canceled. (-128)") == .cancelled)
+ #expect(ClosedLidPermission.outcome(status: 1, stderr: "boom\n") == .failed("boom"))
+ #expect(
+ ClosedLidPermission.outcome(status: 1, stderr: "")
+ == .failed("The administrator command failed."))
+ #expect(ClosedLidPermission.outcome(status: 0, stderr: "") == .succeeded)
+ #expect(
+ ClosedLidPermission.outcome(status: 15, stderr: "", timedOut: true)
+ == .failed("The administrator prompt timed out."))
+ }
+
+ @Test func theProbeChecksBothPmsetCommands() {
+ #expect(
+ ClosedLidPermission.probeArgumentSets == [
+ ["-n", "-l", "/usr/bin/pmset", "-a", "disablesleep", "1"],
+ ["-n", "-l", "/usr/bin/pmset", "-a", "disablesleep", "0"],
+ ])
+ }
+
+ @Test func theOsascriptRunnerMapsExitStatusAndStderr() async {
+ let sh = URL(fileURLWithPath: "/bin/sh")
+ func run(_ command: String, timeout: TimeInterval = 10) async -> PrivilegedScriptOutcome {
+ await OsascriptAdminRunner.execute(
+ executable: sh, arguments: ["-c", command], timeout: timeout)
+ }
+ #expect(await run("exit 0") == .succeeded)
+ #expect(await run("echo 'execution error: User canceled. (-128)' >&2; exit 1") == .cancelled)
+ #expect(await run("echo 'nope' >&2; exit 1") == .failed("nope"))
+ #expect(
+ await OsascriptAdminRunner.execute(
+ executable: URL(fileURLWithPath: "/nonexistent/osascript"), arguments: [], timeout: 5)
+ == .failed("The administrator command couldn't start."))
+ #expect(
+ await run("exec sleep 5", timeout: 0.3) == .failed("The administrator prompt timed out."))
+ }
+
+ @Test func theAdminPromptBoundIsFiveMinutes() {
+ // Separate from BoundedProcess, which caps at 3 seconds and would kill the prompt.
+ #expect(OsascriptAdminRunner.promptTimeout == 300)
+ }
+
+ @Test func aFailedScriptReportsTheStateAfterward() async {
+ let runner = RecordingRunner(outcome: .failed("visudo said no"))
+ let outcome = await permission(info: nil, probe: false, runner: runner).install()
+ #expect(outcome == .failed("visudo said no", .notInstalled))
+ }
+
+ @Test func installRefusesWithoutPromptingForNonAdmins() async {
+ let runner = RecordingRunner(outcome: .succeeded)
+ let outcome = await permission(admin: false, runner: runner).install()
+ guard case .failed(_, .notAdmin) = outcome else {
+ Issue.record("expected a notAdmin failure, got \(outcome)")
+ return
+ }
+ #expect(await runner.scripts.isEmpty)
+ }
+
+ @Test func restoreNormalSleepRunsTheFixedPmsetCommand() async {
+ let runner = RecordingRunner(outcome: .succeeded)
+ #expect(await permission(runner: runner).restoreNormalSleep() == .succeeded)
+ let scripts = await runner.scripts
+ #expect(scripts.count == 1)
+ #expect(scripts[0].contains("/usr/bin/pmset -a disablesleep 0"))
+ #expect(scripts[0].hasSuffix("with administrator privileges"))
+ let cancelled = RecordingRunner(outcome: .cancelled)
+ #expect(await permission(runner: cancelled).restoreNormalSleep() == .cancelled)
+ }
+
+ @Test func visudoAcceptsTheFixedContent() throws {
+ let visudo = "/usr/sbin/visudo"
+ guard FileManager.default.isExecutableFile(atPath: visudo) else { return }
+ let file = FileManager.default.temporaryDirectory
+ .appendingPathComponent("dilated-sudoers-\(UUID().uuidString)")
+ try ClosedLidPermission.ruleContent.write(to: file)
+ defer { try? FileManager.default.removeItem(at: file) }
+ try FileManager.default.setAttributes([.posixPermissions: 0o440], ofItemAtPath: file.path)
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: visudo)
+ process.arguments = ["-cf", file.path]
+ let output = Pipe()
+ process.standardOutput = output
+ process.standardError = output
+ try process.run()
+ process.waitUntilExit()
+ let text = String(decoding: output.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
+ #expect(process.terminationStatus == 0, "\(text)")
+ }
+}
+
+private actor RecordingRunner: PrivilegedScriptRunning {
+ private(set) var scripts: [String] = []
+ private let outcome: PrivilegedScriptOutcome
+ init(outcome: PrivilegedScriptOutcome) { self.outcome = outcome }
+ func run(script: String) -> PrivilegedScriptOutcome {
+ scripts.append(script)
+ return outcome
+ }
+}
diff --git a/Tests/DilatedCoreTests/ClosedLidSessionTests.swift b/Tests/DilatedCoreTests/ClosedLidSessionTests.swift
new file mode 100644
index 0000000..f5495fb
--- /dev/null
+++ b/Tests/DilatedCoreTests/ClosedLidSessionTests.swift
@@ -0,0 +1,298 @@
+import Foundation
+import Testing
+
+@testable import DilatedCore
+
+private let disabledState = ClosedLidReadback(effectiveDisabled: true, storedDisabled: true)
+private let enabledState = ClosedLidReadback(effectiveDisabled: false, storedDisabled: false)
+
+/// Records every call and whether the marker existed when it happened.
+private actor RecordingPower: ClosedLidPowerControlling {
+ private let store: ClosedLidMarkerStore
+ private(set) var calls: [String] = []
+ private(set) var state: ClosedLidReadback
+ private var failing = false
+
+ init(store: ClosedLidMarkerStore, state: ClosedLidReadback = enabledState) {
+ self.store = store
+ self.state = state
+ }
+
+ func setSleepDisabled(_ disabled: Bool) async throws -> ClosedLidReadback {
+ calls.append("\(disabled ? "disable" : "enable") marker=\(store.read() != nil)")
+ if failing { throw ClosedLidPowerError.permissionUnavailable }
+ state = disabled ? disabledState : enabledState
+ return state
+ }
+ func readback() async -> ClosedLidReadback { state }
+ func sleep() async throws {}
+ func fail(_ value: Bool) { failing = value }
+ func setState(_ value: ClosedLidReadback) { state = value }
+}
+
+private final class LaunchLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var log: [ClosedLidWatchdogLaunch] = []
+ var values: [ClosedLidWatchdogLaunch] { lock.withLock { log } }
+ func record(_ launch: ClosedLidWatchdogLaunch) { lock.withLock { log.append(launch) } }
+}
+
+private func makeStore() -> ClosedLidMarkerStore {
+ ClosedLidMarkerStore(
+ directory: FileManager.default.temporaryDirectory.appendingPathComponent(
+ "lid-session-\(UUID().uuidString)"))
+}
+
+private func staleMarker(pid: Int32 = 999_999, start: Int64 = 1_000) -> ClosedLidSessionMarker {
+ ClosedLidSessionMarker(
+ pid: pid, processStartMicros: start, generation: UUID(),
+ enabledAt: Date(timeIntervalSince1970: 1))
+}
+
+struct ClosedLidSessionTests {
+ @Test func theMarkerIsWrittenBeforeEnableAndClearedOnlyAfterConfirmedRevert() async throws {
+ let store = makeStore()
+ let power = RecordingPower(store: store)
+ let launches = LaunchLog()
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, launchWatchdog: { launches.record($0) })
+ _ = try await guardian.setSleepDisabled(true)
+ let marker = try #require(store.read())
+ #expect(marker.pid == getpid())
+ #expect(marker.processStartMicros == ProcessStartTime.micros(pid: getpid()))
+ #expect(await power.calls == ["disable marker=true"])
+ _ = try await guardian.setSleepDisabled(false)
+ #expect(store.read() == nil)
+ #expect(await power.calls.last == "enable marker=true")
+ }
+
+ @Test func aRevertThatIsNotConfirmedKeepsTheMarker() async throws {
+ let store = makeStore()
+ let power = RecordingPower(store: store)
+ let guardian = ClosedLidSessionGuard(power: power, store: store)
+ _ = try await guardian.setSleepDisabled(true)
+ await power.fail(true)
+ await #expect(throws: (any Error).self) { try await guardian.setSleepDisabled(false) }
+ #expect(store.read() != nil)
+ }
+
+ @Test func oneWatchdogLaunchesPerGeneration() async throws {
+ let store = makeStore()
+ let launches = LaunchLog()
+ let power = RecordingPower(store: store)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, launchWatchdog: { launches.record($0) })
+ _ = try await guardian.setSleepDisabled(true)
+ _ = try await guardian.setSleepDisabled(true)
+ #expect(launches.values.count == 1)
+ let first = try #require(launches.values.first)
+ #expect(first.generation == store.read()?.generation)
+ #expect(first.pid == getpid())
+ _ = try await guardian.setSleepDisabled(false)
+ _ = try await guardian.setSleepDisabled(true)
+ #expect(launches.values.count == 2)
+ #expect(launches.values[1].generation != first.generation)
+ }
+
+ @Test func concurrentEnablesStillLaunchOneWatchdog() async throws {
+ let store = makeStore()
+ let launches = LaunchLog()
+ let power = RecordingPower(store: store)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, launchWatchdog: { launches.record($0) })
+ try await withThrowingTaskGroup(of: Void.self) { group in
+ for _ in 0..<8 { group.addTask { _ = try await guardian.setSleepDisabled(true) } }
+ try await group.waitForAll()
+ }
+ #expect(launches.values.count == 1)
+ }
+
+ @Test func aFailedWatchdogLaunchRefusesToEnable() async throws {
+ struct Refused: Error {}
+ let store = makeStore()
+ let power = RecordingPower(store: store)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, launchWatchdog: { _ in throw Refused() })
+ await #expect(throws: Refused.self) { try await guardian.setSleepDisabled(true) }
+ #expect(store.read() == nil)
+ #expect(await power.calls.isEmpty)
+ }
+
+ @Test func aFailedEnableClearsTheMarkerAndTheSession() async throws {
+ let store = makeStore()
+ let launches = LaunchLog()
+ let power = RecordingPower(store: store)
+ await power.fail(true)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, launchWatchdog: { launches.record($0) })
+ await #expect(throws: (any Error).self) { try await guardian.setSleepDisabled(true) }
+ #expect(store.read() == nil)
+ await power.fail(false)
+ _ = try await guardian.setSleepDisabled(true)
+ // The failed session was dropped, so this is a fresh generation with its own watchdog.
+ #expect(launches.values.count == 2)
+ #expect(launches.values[1].generation != launches.values[0].generation)
+ #expect(store.read()?.generation == launches.values[1].generation)
+ }
+
+ @Test func anUnreadableOrZeroStartTimeRefusesToEnable() async throws {
+ for start: Int64? in [nil, 0] {
+ let store = makeStore()
+ let launches = LaunchLog()
+ let power = RecordingPower(store: store)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, startMicros: { _ in start },
+ launchWatchdog: { launches.record($0) })
+ await #expect(throws: ClosedLidSessionError.self) {
+ try await guardian.setSleepDisabled(true)
+ }
+ #expect(store.read() == nil)
+ #expect(launches.values.isEmpty)
+ #expect(await power.calls.isEmpty)
+ }
+ }
+
+ @Test func theGuardWaitsForTheLockBeforeWritingAMarkerOrEnabling() async throws {
+ let store = makeStore()
+ let power = RecordingPower(store: store)
+ let held = try await store.acquireLock()
+ let guardian = ClosedLidSessionGuard(power: power, store: store)
+ let enabling = Task { try await guardian.setSleepDisabled(true) }
+ try await Task.sleep(for: .milliseconds(200))
+ #expect(store.read() == nil)
+ #expect(await power.calls.isEmpty)
+ held.release()
+ _ = try await enabling.value
+ #expect(store.read() != nil)
+ #expect(await power.calls == ["disable marker=true"])
+ }
+
+ @Test func aLockThatNeverFreesTimesOutInsteadOfBlocking() async throws {
+ let store = makeStore()
+ let held = try await store.acquireLock()
+ defer { held.release() }
+ await #expect(throws: ClosedLidSessionError.lockTimeout) {
+ _ = try await store.acquireLock(timeout: .milliseconds(100))
+ }
+ }
+
+ @Test func relaunchWithADeadPidRevertsAndClears() async throws {
+ let store = makeStore()
+ try store.write(staleMarker())
+ let power = RecordingPower(store: store, state: disabledState)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, startMicros: { _ in nil })
+ _ = try await guardian.setSleepDisabled(false)
+ #expect(await guardian.recovery == .reverted)
+ #expect(store.read() == nil)
+ #expect(await power.state.confirmedDisabled)
+ }
+
+ @Test func aReusedPidWithADifferentStartTimeCountsAsDead() async throws {
+ let store = makeStore()
+ try store.write(staleMarker(pid: 4242, start: 1_000))
+ let power = RecordingPower(store: store, state: disabledState)
+ let guardian = ClosedLidSessionGuard(power: power, store: store, startMicros: { _ in 2_000 })
+ _ = try await guardian.setSleepDisabled(false)
+ #expect(await guardian.recovery == .reverted)
+ #expect(store.read() == nil)
+ }
+
+ @Test func aLiveProcessWithTheSameStartTimeIsLeftAlone() async throws {
+ let store = makeStore()
+ try store.write(staleMarker(pid: 4242, start: 1_000))
+ let power = RecordingPower(store: store, state: disabledState)
+ let guardian = ClosedLidSessionGuard(power: power, store: store, startMicros: { _ in 1_000 })
+ let observed = try await guardian.setSleepDisabled(false)
+ #expect(observed == disabledState)
+ #expect(await guardian.recovery == .activeElsewhere)
+ #expect(await power.calls.isEmpty)
+ #expect(store.read() != nil)
+ }
+
+ @Test func disabledSleepWithNoMarkerIsLeftAlone() async throws {
+ let store = makeStore()
+ let power = RecordingPower(store: store, state: disabledState)
+ let guardian = ClosedLidSessionGuard(power: power, store: store)
+ let observed = try await guardian.setSleepDisabled(false)
+ #expect(observed == disabledState)
+ #expect(await guardian.recovery == .foreignSleepDisabled)
+ #expect(await power.calls.isEmpty)
+ // Once the other tool lets go, the normal path resumes.
+ await power.setState(enabledState)
+ _ = try await guardian.setSleepDisabled(false)
+ #expect(await guardian.recovery == .clean)
+ }
+
+ @Test func aFailedRelaunchRevertKeepsTheMarkerAndFailsClosed() async throws {
+ let store = makeStore()
+ let marker = staleMarker()
+ try store.write(marker)
+ let power = RecordingPower(store: store, state: disabledState)
+ await power.fail(true)
+ let guardian = ClosedLidSessionGuard(power: power, store: store, startMicros: { _ in nil })
+ await #expect(throws: (any Error).self) { try await guardian.setSleepDisabled(false) }
+ await #expect(throws: (any Error).self) { try await guardian.setSleepDisabled(true) }
+ guard case .failed = await guardian.recovery else {
+ Issue.record("expected a failed recovery")
+ return
+ }
+ #expect(store.read() == marker)
+ // Permission comes back: the next call reverts and clears.
+ await power.fail(false)
+ _ = try await guardian.setSleepDisabled(false)
+ #expect(await guardian.recovery == .reverted)
+ #expect(store.read() == nil)
+ }
+
+ @Test func aManualRestoreClearsAFailedRevertOnlyWhenMacOSConfirmsIt() async throws {
+ let store = makeStore()
+ let marker = staleMarker()
+ try store.write(marker)
+ let power = RecordingPower(store: store, state: disabledState)
+ await power.fail(true)
+ let guardian = ClosedLidSessionGuard(power: power, store: store, startMicros: { _ in nil })
+ await #expect(throws: (any Error).self) { try await guardian.setSleepDisabled(false) }
+ // Sleep is still disabled: nothing changes.
+ #expect(await guardian.acknowledgeManualRestore() == false)
+ #expect(store.read() == marker)
+ // The password-backed restore worked.
+ await power.setState(enabledState)
+ #expect(await guardian.acknowledgeManualRestore() == true)
+ #expect(await guardian.recovery == .reverted)
+ #expect(store.read() == nil)
+ }
+
+ @Test func aStaleMarkerIsRevertedBeforeANewSessionStarts() async throws {
+ let store = makeStore()
+ try store.write(staleMarker())
+ let power = RecordingPower(store: store, state: disabledState)
+ let guardian = ClosedLidSessionGuard(
+ power: power, store: store, startMicros: { $0 == 999_999 ? nil : 5_000 })
+ _ = try await guardian.setSleepDisabled(true)
+ let calls = await power.calls
+ #expect(calls.count == 2)
+ #expect(calls[0].hasPrefix("enable"))
+ #expect(calls[1].hasPrefix("disable"))
+ }
+
+ @Test func clearIgnoresAMarkerFromAnotherGeneration() throws {
+ let store = makeStore()
+ let marker = staleMarker()
+ try store.write(marker)
+ store.clear(generation: UUID())
+ #expect(store.read() == marker)
+ store.clear(generation: marker.generation)
+ #expect(store.read() == nil)
+ }
+
+ @Test func watchdogArgumentsRoundTrip() throws {
+ let launch = ClosedLidWatchdogLaunch(
+ pid: 123, marker: URL(fileURLWithPath: "/tmp/x/closed-lid-session.json"),
+ generation: UUID())
+ #expect(
+ ClosedLidWatchdogCommand.parse(ClosedLidWatchdogCommand.arguments(for: launch)) == launch)
+ #expect(ClosedLidWatchdogCommand.parse(["status"]) == nil)
+ #expect(ClosedLidWatchdogCommand.parse([ClosedLidWatchdogCommand.name, "--pid", "1"]) == nil)
+ }
+}
diff --git a/Tests/DilatedCoreTests/ClosedLidWatchdogTests.swift b/Tests/DilatedCoreTests/ClosedLidWatchdogTests.swift
new file mode 100644
index 0000000..1e50bfb
--- /dev/null
+++ b/Tests/DilatedCoreTests/ClosedLidWatchdogTests.swift
@@ -0,0 +1,359 @@
+import Foundation
+import Testing
+
+@testable import DilatedCore
+
+private let offState = ClosedLidReadback(effectiveDisabled: false, storedDisabled: false)
+
+private actor WatchdogPower: ClosedLidPowerControlling {
+ private(set) var calls: [Bool] = []
+ private var failing: Bool
+ private var failuresRemaining: Int
+ init(failing: Bool = false, failFirst: Int = 0) {
+ self.failing = failing
+ failuresRemaining = failFirst
+ }
+ func setSleepDisabled(_ disabled: Bool) async throws -> ClosedLidReadback {
+ calls.append(disabled)
+ if failing { throw ClosedLidPowerError.commandFailed(1) }
+ if failuresRemaining > 0 {
+ failuresRemaining -= 1
+ throw ClosedLidPowerError.commandFailed(1)
+ }
+ return offState
+ }
+ func readback() async -> ClosedLidReadback { offState }
+ func sleep() async throws {}
+}
+
+private final class MarkerBox: @unchecked Sendable {
+ private let lock = NSLock()
+ private var marker: ClosedLidSessionMarker?
+ init(_ marker: ClosedLidSessionMarker?) { self.marker = marker }
+ func read() -> ClosedLidSessionMarker? { lock.withLock { marker } }
+ func set(_ value: ClosedLidSessionMarker?) { lock.withLock { marker = value } }
+}
+
+private func marker(_ generation: UUID, pid: Int32 = 1) -> ClosedLidSessionMarker {
+ ClosedLidSessionMarker(
+ pid: pid, processStartMicros: 1, generation: generation, enabledAt: Date())
+}
+
+private let immediateExit: @Sendable (@escaping @Sendable () -> Void) -> @Sendable () -> Void = {
+ handler in
+ handler()
+ return {}
+}
+
+private let neverExits: @Sendable (@escaping @Sendable () -> Void) -> @Sendable () -> Void = { _ in
+ {}
+}
+
+private let noSleep: @Sendable (Duration) async throws -> Void = { _ in }
+
+struct ClosedLidWatchdogTests {
+ @Test func appExitRevertsAndClearsTheMarker() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation,
+ readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: { box.set(nil) })
+ #expect(await watchdog.run() == .reverted)
+ #expect(await power.calls == [false])
+ #expect(box.read() == nil)
+ }
+
+ @Test func aMarkerThatDisappearsEndsTheWatchdogWithoutReverting() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower()
+ let reads = Counter()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation,
+ pollInterval: .milliseconds(20),
+ readMarker: { reads.next() >= 3 ? nil : box.read() },
+ onExit: neverExits, power: power,
+ clearMarker: {})
+ #expect(await watchdog.run() == .markerGone)
+ #expect(await power.calls.isEmpty)
+ }
+
+ @Test func aNewGenerationIsNotRevertedByTheOldWatchdog() async {
+ let box = MarkerBox(marker(UUID()))
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: UUID(),
+ readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: { box.set(nil) })
+ #expect(await watchdog.run() == .markerGone)
+ #expect(await power.calls.isEmpty)
+ #expect(box.read() != nil)
+ }
+
+ @Test func theMarkerGoingAwayAtTheMomentOfExitDoesNotRevert() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation,
+ readMarker: { box.read() },
+ onExit: { handler in
+ box.set(nil)
+ handler()
+ return {}
+ }, power: power,
+ clearMarker: {})
+ #expect(await watchdog.run() == .markerGone)
+ #expect(await power.calls.isEmpty)
+ }
+
+ @Test func aFailedRevertKeepsTheMarkerUntilItIsCancelled() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower(failing: true)
+ let sleeps = DurationLog()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation, pollInterval: .seconds(1000),
+ readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: { box.set(nil) },
+ sleep: { duration in
+ if sleeps.record(duration) >= 3 { throw CancellationError() }
+ })
+ #expect(await watchdog.run() == .revertFailed)
+ #expect(box.read() != nil)
+ #expect(await power.calls == [false, false, false])
+ }
+
+ @Test func revertBackoffDoublesFromFiveSecondsToAFiveMinuteCap() {
+ let schedule = (0..<9).map { ClosedLidWatchdog.revertBackoff(attempt: $0) }
+ #expect(
+ schedule == [5, 10, 20, 40, 80, 160, 300, 300, 300].map { Duration.seconds($0) })
+ }
+
+ @Test func aFailingRevertRetriesWithBackoffUntilItConfirms() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower(failFirst: 7)
+ let sleeps = DurationLog()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation, pollInterval: .seconds(1000),
+ readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: { box.set(nil) },
+ sleep: { duration in
+ _ = sleeps.record(duration)
+ })
+ #expect(await watchdog.run() == .reverted)
+ #expect(sleeps.values == [5, 10, 20, 40, 80, 160, 300].map { Duration.seconds($0) })
+ #expect(await power.calls.count == 8)
+ #expect(box.read() == nil)
+ }
+
+ @Test func theRetryStopsOnceTheMarkerIsNoLongerOurs() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower(failing: true)
+ let watchdog = ClosedLidWatchdog(
+ generation: generation, pollInterval: .seconds(1000),
+ readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: {},
+ sleep: { duration in
+ box.set(marker(UUID()))
+ })
+ #expect(await watchdog.run() == .markerGone)
+ #expect(await power.calls == [false])
+ }
+
+ @Test func thereIsNoLifetimeCapWhileTheMarkerIsOurs() async {
+ let generation = UUID()
+ let box = MarkerBox(marker(generation))
+ let power = WatchdogPower()
+ let reads = Counter()
+ // Many polls pass with the marker still ours and nothing happens; the watchdog only ends
+ // once the marker goes away.
+ let watchdog = ClosedLidWatchdog(
+ generation: generation, pollInterval: .milliseconds(5),
+ readMarker: { reads.next() >= 40 ? nil : box.read() },
+ onExit: neverExits, power: power,
+ clearMarker: {})
+ #expect(await watchdog.run() == .markerGone)
+ #expect(reads.next() > 40)
+ #expect(await power.calls.isEmpty)
+ }
+
+ @Test func theLockStopsTheWatchdogRevertingANewGeneration() async throws {
+ let old = UUID()
+ let store = ClosedLidMarkerStore(
+ directory: FileManager.default.temporaryDirectory.appendingPathComponent(
+ "lid-watchdog-lock-\(UUID().uuidString)"))
+ try store.write(marker(old))
+ // The app holds the lock while it replaces the marker.
+ let appLock = try await store.acquireLock()
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: old, pollInterval: .seconds(1000),
+ readMarker: { store.read() }, onExit: immediateExit, power: power,
+ clearMarker: { store.clear(generation: old) },
+ acquireLock: {
+ guard let lock = try? await store.acquireLock() else { return nil }
+ return { lock.release() }
+ })
+ let outcome = Task { await watchdog.run() }
+ try await Task.sleep(for: .milliseconds(200))
+ #expect(await power.calls.isEmpty)
+ try store.write(marker(UUID()))
+ appLock.release()
+ #expect(await outcome.value == .markerGone)
+ #expect(await power.calls.isEmpty)
+ #expect(store.read() != nil)
+ }
+
+ @Test func aZeroStartTimeMarkerIsNeverActedOn() async {
+ let generation = UUID()
+ let zero = ClosedLidSessionMarker(
+ pid: 1, processStartMicros: 0, generation: generation, enabledAt: Date())
+ let box = MarkerBox(zero)
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation, readMarker: { box.read() }, onExit: immediateExit, power: power,
+ clearMarker: { box.set(nil) })
+ #expect(await watchdog.run() == .markerGone)
+ #expect(await power.calls.isEmpty)
+ #expect(box.read() != nil)
+ }
+
+ @Test func killingARealAppProcessRestoresSleep() async throws {
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sleep")
+ child.arguments = ["20"]
+ child.standardOutput = FileHandle.nullDevice
+ child.standardError = FileHandle.nullDevice
+ try child.run()
+ defer { if child.isRunning { child.terminate() } }
+ let pid = child.processIdentifier
+ let start = try #require(ProcessStartTime.micros(pid: pid))
+ let generation = UUID()
+ let store = ClosedLidMarkerStore(
+ directory: FileManager.default.temporaryDirectory.appendingPathComponent(
+ "lid-watchdog-\(UUID().uuidString)"))
+ try store.write(
+ ClosedLidSessionMarker(
+ pid: pid, processStartMicros: start, generation: generation, enabledAt: Date()))
+ let power = WatchdogPower()
+ let watchdog = ClosedLidWatchdog(
+ generation: generation,
+ readMarker: { store.read() },
+ onExit: { handler in
+ ProcessExit.register(pid: pid, startMicros: start, handler: handler)
+ },
+ power: power, clearMarker: { store.clear(generation: generation) })
+ let outcome = Task { await watchdog.run() }
+ try await Task.sleep(for: .milliseconds(200))
+ #expect(await power.calls.isEmpty)
+ kill(pid, SIGKILL)
+ #expect(await outcome.value == .reverted)
+ #expect(await power.calls == [false])
+ #expect(store.read() == nil)
+ for _ in 0..<500 where child.isRunning { try await Task.sleep(for: .milliseconds(10)) }
+ }
+
+ @Test func waitingOnAnExitedProcessReturnsAtOnce() async throws {
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/usr/bin/true")
+ try child.run()
+ let pid = child.processIdentifier
+ let start = ProcessStartTime.micros(pid: pid) ?? 0
+ for _ in 0..<500 where child.isRunning { try await Task.sleep(for: .milliseconds(10)) }
+ await ProcessExit.wait(pid: pid, startMicros: start)
+ await ProcessExit.wait(pid: getpid(), startMicros: 1)
+ let fired = Counter()
+ _ = ProcessExit.register(pid: getpid(), startMicros: 1) { _ = fired.next() }
+ #expect(fired.next() == 2)
+ }
+}
+
+private final class Counter: @unchecked Sendable {
+ private let lock = NSLock()
+ private var value = 0
+ func next() -> Int {
+ lock.withLock {
+ value += 1
+ return value
+ }
+ }
+}
+
+private final class DurationLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var log: [Duration] = []
+ var values: [Duration] { lock.withLock { log } }
+ func record(_ duration: Duration) -> Int {
+ lock.withLock {
+ log.append(duration)
+ return log.count
+ }
+ }
+}
+
+struct ClosedLidWatchdogCommandTests {
+ private let marker = "/tmp/x/closed-lid-session.json"
+ private let generation = UUID()
+
+ private func arguments(_ overrides: [String: String?] = [:], extra: [String] = []) -> [String] {
+ var values: [String: String?] = [
+ "--pid": "123", "--marker": marker, "--generation": generation.uuidString,
+ ]
+ for (key, value) in overrides { values[key] = value }
+ let flat = values.sorted { $0.key < $1.key }.flatMap { key, value in
+ value.map { [key, $0] } ?? []
+ }
+ return [ClosedLidWatchdogCommand.name] + flat + extra
+ }
+
+ @Test func parsesTheFullArgumentList() {
+ let launch = ClosedLidWatchdogCommand.parse(arguments())
+ #expect(
+ launch
+ == ClosedLidWatchdogLaunch(
+ pid: 123, marker: URL(fileURLWithPath: marker), generation: generation))
+ }
+
+ @Test func rejectsMalformedArguments() {
+ #expect(ClosedLidWatchdogCommand.parse([]) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(["status"]) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--pid": nil])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--marker": nil])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--generation": nil])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--pid": "0"])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--pid": "-5"])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--pid": "abc"])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--marker": "relative/path"])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(["--generation": "nope"])) == nil)
+ #expect(ClosedLidWatchdogCommand.parse(arguments(extra: ["--dangling"])) == nil)
+ }
+
+ @Test func runDoesNothingWithoutAMatchingUsableMarker() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "lid-command-\(UUID().uuidString)")
+ let store = ClosedLidMarkerStore(directory: directory)
+ let launch = ClosedLidWatchdogLaunch(pid: 123, marker: store.file, generation: generation)
+ #expect(await ClosedLidWatchdogCommand.run(launch) == 0)
+ // A different generation, a different pid and a zero start time all exit without acting.
+ for stored in [
+ ClosedLidSessionMarker(
+ pid: 123, processStartMicros: 5, generation: UUID(),
+ enabledAt: Date(timeIntervalSince1970: 1)),
+ ClosedLidSessionMarker(
+ pid: 999, processStartMicros: 5, generation: generation,
+ enabledAt: Date(timeIntervalSince1970: 1)),
+ ClosedLidSessionMarker(
+ pid: 123, processStartMicros: 0, generation: generation,
+ enabledAt: Date(timeIntervalSince1970: 1)),
+ ] {
+ try store.write(stored)
+ #expect(await ClosedLidWatchdogCommand.run(launch) == 0)
+ #expect(store.read() == stored)
+ }
+ }
+}
diff --git a/Tests/DilatedUITests/ClosedLidPermissionUITests.swift b/Tests/DilatedUITests/ClosedLidPermissionUITests.swift
new file mode 100644
index 0000000..7a24a73
--- /dev/null
+++ b/Tests/DilatedUITests/ClosedLidPermissionUITests.swift
@@ -0,0 +1,434 @@
+import AppKit
+import DilatedControl
+import DilatedCore
+import Foundation
+import SwiftUI
+import Testing
+import ViewInspector
+
+@testable import DilatedUI
+
+private final class PermissionWorld: @unchecked Sendable {
+ private let lock = NSLock()
+ private var _info: ClosedLidFileInfo?
+ private var _content: Data?
+ private var _probe = true
+ private var _admin = true
+ private var _recovery = ClosedLidSessionGuard.Recovery.clean
+ private var _scripts: [String] = []
+ private var _outcomes: [PrivilegedScriptOutcome] = []
+ private var _acknowledged = true
+
+ static let good = ClosedLidFileInfo(isRegularFile: true, owner: 0, group: 0, mode: 0o440)
+
+ func set(
+ info: ClosedLidFileInfo?? = nil, content: Data?? = nil, probe: Bool? = nil, admin: Bool? = nil,
+ recovery: ClosedLidSessionGuard.Recovery? = nil, acknowledged: Bool? = nil
+ ) {
+ lock.withLock {
+ if let info { _info = info }
+ if let content { _content = content }
+ if let probe { _probe = probe }
+ if let admin { _admin = admin }
+ if let recovery { _recovery = recovery }
+ if let acknowledged { _acknowledged = acknowledged }
+ }
+ }
+ func queue(_ outcome: PrivilegedScriptOutcome) { lock.withLock { _outcomes.append(outcome) } }
+ var scripts: [String] { lock.withLock { _scripts } }
+ var recovery: ClosedLidSessionGuard.Recovery { lock.withLock { _recovery } }
+ var acknowledged: Bool { lock.withLock { _acknowledged } }
+ func pop(_ script: String) -> PrivilegedScriptOutcome {
+ lock.withLock {
+ _scripts.append(script)
+ return _outcomes.isEmpty ? .succeeded : _outcomes.removeFirst()
+ }
+ }
+ func snapshot() -> (ClosedLidFileInfo?, Data?, Bool, Bool) {
+ lock.withLock { (_info, _content, _probe, _admin) }
+ }
+}
+
+private struct WorldRunner: PrivilegedScriptRunning {
+ let world: PermissionWorld
+ func run(script: String) async -> PrivilegedScriptOutcome { world.pop(script) }
+}
+
+private actor GateRunner: PrivilegedScriptRunning {
+ private var continuation: CheckedContinuation?
+ private var early: PrivilegedScriptOutcome?
+ func run(script: String) async -> PrivilegedScriptOutcome {
+ if let early { return early }
+ return await withCheckedContinuation { continuation = $0 }
+ }
+ func open(_ outcome: PrivilegedScriptOutcome) {
+ if let continuation {
+ continuation.resume(returning: outcome)
+ self.continuation = nil
+ } else {
+ early = outcome
+ }
+ }
+}
+
+private actor IdlePower: ClosedLidPowerControlling {
+ func setSleepDisabled(_ disabled: Bool) async throws -> ClosedLidReadback {
+ ClosedLidReadback(effectiveDisabled: disabled, storedDisabled: disabled)
+ }
+ func readback() async -> ClosedLidReadback {
+ ClosedLidReadback(effectiveDisabled: false, storedDisabled: false)
+ }
+ func sleep() async throws {}
+}
+
+@MainActor private func makeWorld(_ state: ClosedLidPermissionState) -> PermissionWorld {
+ let world = PermissionWorld()
+ switch state {
+ case .notInstalled: world.set(info: .some(nil), content: .some(nil), probe: false)
+ case .allowedByOtherRule: world.set(info: .some(nil), content: .some(nil), probe: true)
+ case .needsRepair:
+ world.set(info: .some(PermissionWorld.good), content: .some(Data("junk\n".utf8)))
+ case .blocked:
+ world.set(
+ info: .some(PermissionWorld.good), content: .some(ClosedLidPermission.ruleContent),
+ probe: false)
+ case .ready:
+ world.set(info: .some(PermissionWorld.good), content: .some(ClosedLidPermission.ruleContent))
+ case .notAdmin: world.set(admin: false)
+ }
+ return world
+}
+
+@MainActor private func makePermissionModel(_ world: PermissionWorld) -> ClosedLidPermissionModel {
+ let permission = ClosedLidPermission(
+ seams: .init(
+ stat: { _ in world.snapshot().0 }, readFile: { _ in world.snapshot().1 },
+ probe: { world.snapshot().2 }, isAdmin: { world.snapshot().3 }),
+ runner: WorldRunner(world: world))
+ return ClosedLidPermissionModel(
+ permission: permission, recovery: { world.recovery },
+ acknowledgeRestore: { world.acknowledged })
+}
+
+@MainActor private func makeAppModel(
+ permission: ClosedLidPermissionModel, local: Bool, directory: URL
+) -> AppModel {
+ let controller =
+ local
+ ? ClosedLidController(
+ transport: LocalClosedLidTransport(power: IdlePower(), tickInterval: .seconds(3600)),
+ expectedBuild: { LocalClosedLidTransport.buildIdentity })
+ : ClosedLidController()
+ return AppModel(
+ endpoint: .init(stateDirectory: directory), power: { .init() },
+ closedLid: controller,
+ closedLidPermission: permission)
+}
+
+private func texts(_ view: some View) throws -> [String] {
+ try view.inspect().findAll(ViewType.Text.self).map { try $0.string() }
+}
+
+private func button(_ view: some View, _ title: String) throws -> InspectableView {
+ try view.inspect().find(ViewType.Button.self) {
+ (try? $0.labelView().text().string()) == title
+ }
+}
+
+private func buttonTitles(_ view: some View) throws -> [String] {
+ // SettingsRow lays its controls out twice (wide and narrow), so each button appears twice.
+ var seen: [String] = []
+ for title in try view.inspect().findAll(ViewType.Button.self).compactMap({
+ try? $0.labelView().text().string()
+ }) where !seen.contains(title) { seen.append(title) }
+ return seen
+}
+
+@MainActor private func settle(_ condition: @MainActor () -> Bool) async {
+ for _ in 0..<200 {
+ if condition() { return }
+ try? await Task.sleep(for: .milliseconds(10))
+ }
+}
+
+@MainActor struct ClosedLidPermissionUITests {
+ private func scratch() -> URL {
+ FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ }
+
+ @Test(arguments: [
+ (ClosedLidPermissionState.notInstalled, "Closed-lid permission isn't set up", ["Set Up"]),
+ (.needsRepair, "Closed-lid permission needs repair", ["Repair", "Remove"]),
+ (.blocked, "macOS isn't honoring the permission", ["Repair", "Remove"]),
+ (.ready, "Closed-lid permission is set up", ["Remove"]),
+ (.notAdmin, "Closed-lid needs an administrator account", []),
+ (.allowedByOtherRule, "Another app's sudo rule allows closed-lid", ["Set Up"]),
+ ])
+ func eachStateShowsPlainWordsAndTheRightButtons(
+ state: ClosedLidPermissionState, title: String, actions: [String]
+ ) async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let permission = makePermissionModel(makeWorld(state))
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ let view = ClosedLidSettings(model: model)
+ let shown = try texts(view)
+ #expect(shown.contains("PERMISSION"))
+ #expect(shown.contains(title))
+ #expect(!shown.contains("HELPER"))
+ let titles = try buttonTitles(view).filter { ["Set Up", "Repair", "Remove"].contains($0) }
+ #expect(titles == actions)
+ }
+
+ @Test func anotherAppsRuleIsUsableAndExplainsItself() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let permission = makePermissionModel(makeWorld(.allowedByOtherRule))
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ #expect(permission.state == .allowedByOtherRule)
+ #expect(permission.state?.isUsable == true)
+ #expect(!permission.canRemove)
+ #expect(permission.setUpTitle == "Set Up")
+ #expect(permission.summary == "Closed-lid works through another app's sudo rule.")
+ #expect(
+ try texts(ClosedLidSettings(model: model)).contains {
+ $0.contains("Another app's sudo rule already allows this")
+ })
+ let response = await model.control(.init(action: .lidPermissionStatus))
+ #expect(response.ok && response.lidPermission == "allowedByOtherRule")
+ }
+
+ @Test func statusAnswersWhileTheAdminPromptIsOpen() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let gate = GateRunner()
+ let world = makeWorld(.notInstalled)
+ let permission = ClosedLidPermissionModel(
+ permission: ClosedLidPermission(
+ seams: .init(
+ stat: { _ in world.snapshot().0 }, readFile: { _ in world.snapshot().1 },
+ probe: { world.snapshot().2 }, isAdmin: { world.snapshot().3 }),
+ runner: gate))
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ let install = Task { @MainActor in await model.control(.init(action: .lidPermissionInstall)) }
+ await settle { permission.busy }
+ let status = await model.control(.init(action: .lidPermissionStatus))
+ #expect(status.ok)
+ let second = await model.control(.init(action: .lidPermissionInstall))
+ #expect(second.code == "unavailable")
+ await gate.open(.cancelled)
+ let finished = await install.value
+ #expect(finished.code == "cancelled")
+ }
+
+ @Test func notAdminAndBlockedExplainThemselves() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ for (state, phrase) in [
+ (ClosedLidPermissionState.notAdmin, "administrator"), (.blocked, "sudo isn't honoring"),
+ ] {
+ let permission = makePermissionModel(makeWorld(state))
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ #expect(try texts(ClosedLidSettings(model: model)).contains { $0.contains(phrase) })
+ }
+ }
+
+ @Test func setUpRunsTheInstallScriptAndShowsReady() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.notInstalled)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ let view = ClosedLidSettings(model: model)
+ world.set(
+ info: .some(PermissionWorld.good), content: .some(ClosedLidPermission.ruleContent),
+ probe: true)
+ try button(view, "Set Up").tap()
+ await settle { permission.state == .ready }
+ #expect(permission.state == .ready)
+ #expect(world.scripts.count == 1)
+ #expect(world.scripts[0].contains("visudo"))
+ }
+
+ @Test func cancellingTheAdminPromptIsANoOp() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.notInstalled)
+ world.queue(.cancelled)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ try button(ClosedLidSettings(model: model), "Set Up").tap()
+ await settle { !world.scripts.isEmpty && !permission.busy }
+ #expect(world.scripts.count == 1)
+ #expect(permission.state == .notInstalled)
+ #expect(permission.lastError == nil)
+ #expect(model.message == nil)
+ }
+
+ @Test func aFailedInstallShowsTheErrorInline() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.notInstalled)
+ world.queue(.failed("visudo said no"))
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ try button(ClosedLidSettings(model: model), "Set Up").tap()
+ await settle { permission.lastError != nil }
+ #expect(permission.lastError == "visudo said no")
+ #expect(try texts(ClosedLidSettings(model: model)).contains("visudo said no"))
+ }
+
+ @Test func removeRunsTheRemoveScript() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.ready)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ let view = ClosedLidSettings(model: model)
+ world.set(info: .some(nil), content: .some(nil), probe: false)
+ try button(view, "Remove").tap()
+ await settle { permission.state == .notInstalled }
+ #expect(permission.state == .notInstalled)
+ #expect(world.scripts.count == 1)
+ #expect(world.scripts[0].contains("rm"))
+ }
+
+ @Test func aForeignDisableShowsAMessageWithNoAction() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.ready)
+ world.set(recovery: .foreignSleepDisabled)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ let view = ClosedLidSettings(model: model)
+ #expect(try texts(view).contains("Another app has turned off sleep"))
+ #expect(!(try buttonTitles(view).contains("Restore normal sleep")))
+ }
+
+ @Test func aFailedRevertOffersRestoreNormalSleep() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.ready)
+ world.set(recovery: .failed("sudo said no"))
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ await permission.refresh()
+ let view = ClosedLidSettings(model: model)
+ #expect(try texts(view).contains("Dilated couldn't restore normal sleep"))
+ world.set(recovery: .reverted)
+ try button(view, "Restore normal sleep").tap()
+ await settle { !world.scripts.isEmpty && !permission.busy }
+ #expect(world.scripts.count == 1)
+ #expect(world.scripts[0].contains("/usr/bin/pmset -a disablesleep 0"))
+ #expect(permission.recovery == .reverted)
+ #expect(!(try buttonTitles(ClosedLidSettings(model: model)).contains("Restore normal sleep")))
+ }
+
+ @Test func restoreThatMacOSDoesNotConfirmStaysFailed() async throws {
+ let world = makeWorld(.ready)
+ world.set(recovery: .failed("sudo said no"), acknowledged: false)
+ let permission = makePermissionModel(world)
+ let result = await permission.restoreNormalSleep()
+ #expect(result == .failed)
+ #expect(permission.lastError != nil)
+ #expect(permission.recovery == .failed("sudo said no"))
+ }
+
+ @Test func cancellingRestoreChangesNothing() async {
+ let world = makeWorld(.ready)
+ world.set(recovery: .failed("sudo said no"))
+ world.queue(.cancelled)
+ let permission = makePermissionModel(world)
+ await permission.refresh()
+ #expect(await permission.restoreNormalSleep() == .cancelled)
+ #expect(permission.lastError == nil)
+ #expect(permission.recovery == .failed("sudo said no"))
+ }
+
+ @Test func flagOffRendersTheOldHelperGroupUnchanged() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.ready)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: false, directory: directory)
+ defer { model.shutdown() }
+ let view = ClosedLidSettings(model: model)
+ let shown = try texts(view)
+ #expect(shown.contains("HELPER"))
+ #expect(!shown.contains("PERMISSION"))
+ #expect(shown.contains("Check helper status"))
+ #expect(
+ shown.contains(
+ "Approve the signed background helper in Login Items. Regular awake holds work without it."
+ ))
+ #expect(!(try buttonTitles(view).contains("Remove")))
+ #expect(world.scripts.isEmpty)
+ }
+
+ @Test func lidPermissionControlStatusInstallAndRemove() async throws {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.notInstalled)
+ let permission = makePermissionModel(world)
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ var response = await model.control(.init(action: .lidPermissionStatus))
+ #expect(response.ok && response.lidPermission == "notInstalled")
+ world.queue(.cancelled)
+ response = await model.control(.init(action: .lidPermissionInstall))
+ #expect(response.ok && response.code == "cancelled")
+ world.set(
+ info: .some(PermissionWorld.good), content: .some(ClosedLidPermission.ruleContent),
+ probe: true)
+ response = await model.control(.init(action: .lidPermissionInstall))
+ #expect(response.ok && response.lidPermission == "ready")
+ world.queue(.failed("nope"))
+ response = await model.control(.init(action: .lidPermissionRemove))
+ #expect(!response.ok && response.message == "nope" && response.code == "permissionFailure")
+ world.set(info: .some(nil), content: .some(nil), probe: false)
+ response = await model.control(.init(action: .lidPermissionRemove))
+ #expect(response.ok && response.lidPermission == "notInstalled")
+ }
+
+ @Test func helperStatusMapsToThePermissionStateWithADeprecationNote() async {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let permission = makePermissionModel(makeWorld(.ready))
+ let model = makeAppModel(permission: permission, local: true, directory: directory)
+ defer { model.shutdown() }
+ let response = await model.control(.init(action: .helperStatus))
+ #expect(response.ok)
+ #expect(response.lidPermission == "ready")
+ #expect(response.message?.contains("deprecated") == true)
+ }
+
+ @Test func lidPermissionControlIsUnavailableWhenTheFlagIsOff() async {
+ let directory = scratch()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let world = makeWorld(.ready)
+ let model = makeAppModel(
+ permission: makePermissionModel(world), local: false, directory: directory)
+ defer { model.shutdown() }
+ let response = await model.control(.init(action: .lidPermissionInstall))
+ #expect(!response.ok && response.code == "unavailable")
+ #expect(world.scripts.isEmpty)
+ }
+}
diff --git a/Tests/DilatedUITests/ClosedLidWatchdogSpawnerTests.swift b/Tests/DilatedUITests/ClosedLidWatchdogSpawnerTests.swift
new file mode 100644
index 0000000..0958e08
--- /dev/null
+++ b/Tests/DilatedUITests/ClosedLidWatchdogSpawnerTests.swift
@@ -0,0 +1,126 @@
+import DilatedCore
+import Foundation
+import Testing
+
+@testable import DilatedUI
+
+/// Runs the real `dilated` executable, built next to the test bundle. The watchdog waits on a
+/// stand-in "app" (a `sleep` child) and is killed before that child, so it never reaches sudo.
+private final class BundleAnchor {}
+
+struct ClosedLidWatchdogSpawnerTests {
+ private static var executable: URL? {
+ Bundle(for: BundleAnchor.self).bundleURL.deletingLastPathComponent()
+ .appendingPathComponent("dilated")
+ }
+
+ private struct Fixture {
+ let app: Process
+ let launch: ClosedLidWatchdogLaunch
+
+ init() throws {
+ app = Process()
+ app.executableURL = URL(fileURLWithPath: "/bin/sleep")
+ app.arguments = ["20"]
+ // Never inherit the test runner's output pipe, or an orphan holds CI's log open.
+ app.standardOutput = FileHandle.nullDevice
+ app.standardError = FileHandle.nullDevice
+ try app.run()
+ let start = try #require(ProcessStartTime.micros(pid: app.processIdentifier))
+ let store = ClosedLidMarkerStore(
+ directory: FileManager.default.temporaryDirectory.appendingPathComponent(
+ "lid-spawn-\(UUID().uuidString)"))
+ let generation = UUID()
+ try store.write(
+ ClosedLidSessionMarker(
+ pid: app.processIdentifier, processStartMicros: start, generation: generation,
+ enabledAt: Date()))
+ launch = ClosedLidWatchdogLaunch(
+ pid: app.processIdentifier, marker: store.file, generation: generation)
+ }
+
+ func cleanUp(watchdog: pid_t?) {
+ if let watchdog { kill(watchdog, SIGKILL) }
+ app.terminate()
+ try? FileManager.default.removeItem(at: launch.marker.deletingLastPathComponent())
+ }
+ }
+
+ @Test func theWatchdogRunsInItsOwnSession() throws {
+ let executable = try #require(Self.executable)
+ let fixture = try Fixture()
+ var watchdog: pid_t?
+ defer { fixture.cleanUp(watchdog: watchdog) }
+ watchdog = try ClosedLidWatchdogSpawner.spawn(fixture.launch, executable: executable)
+ let child = try #require(watchdog)
+ #expect(getsid(child) != getsid(0))
+ #expect(getsid(child) == child)
+ }
+
+ @Test func theWatchdogSurvivesItsSpawnerDying() async throws {
+ let executable = try #require(Self.executable)
+ let fixture = try Fixture()
+ var watchdog: pid_t?
+ defer { fixture.cleanUp(watchdog: watchdog) }
+ // Stand-in for the app: an intermediate that starts the watchdog in a new session (the
+ // posix_spawn SETSID flag has no perl equivalent, so setsid() before exec), then gets killed.
+ let intermediate = Process()
+ let output = Pipe()
+ intermediate.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
+ intermediate.arguments =
+ [
+ "-MPOSIX", "-e",
+ "my $p = fork; if (!$p) { POSIX::setsid(); exec @ARGV } print \"$p\\n\"; $| = 1; sleep 20",
+ executable.path,
+ ] + ClosedLidWatchdogCommand.arguments(for: fixture.launch)
+ intermediate.standardOutput = output
+ intermediate.standardError = FileHandle.nullDevice
+ try intermediate.run()
+ defer { if intermediate.isRunning { kill(intermediate.processIdentifier, SIGKILL) } }
+ let line = String(
+ decoding: output.fileHandleForReading.availableData, as: UTF8.self)
+ let child = try #require(pid_t(line.trimmingCharacters(in: .whitespacesAndNewlines)))
+ watchdog = child
+ kill(intermediate.processIdentifier, SIGKILL)
+ for _ in 0..<500 where intermediate.isRunning { try await Task.sleep(for: .milliseconds(10)) }
+ try await Task.sleep(for: .milliseconds(600))
+ #expect(kill(child, 0) == 0)
+ #expect(ProcessStartTime.micros(pid: child) != nil)
+ }
+
+ @Test func aNewGenerationReplacesTheOldWatchdog() async throws {
+ let executable = try #require(Self.executable)
+ let first = try Fixture()
+ let second = try Fixture()
+ let current = ClosedLidWatchdogSpawner.Current()
+ var older: pid_t?
+ var newer: pid_t?
+ defer {
+ first.cleanUp(watchdog: older)
+ second.cleanUp(watchdog: newer)
+ }
+ older = try ClosedLidWatchdogSpawner.spawn(
+ first.launch, executable: executable, current: current)
+ newer = try ClosedLidWatchdogSpawner.spawn(
+ second.launch, executable: executable, current: current)
+ let oldPID = try #require(older)
+ let newPID = try #require(newer)
+ #expect(oldPID != newPID)
+ // The replaced watchdog is signalled and reaped; exactly one stays.
+ for _ in 0..<200 where ProcessStartTime.micros(pid: oldPID) != nil {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ #expect(ProcessStartTime.micros(pid: oldPID) == nil)
+ #expect(ProcessStartTime.micros(pid: newPID) != nil)
+ #expect(current.running == newPID)
+ }
+
+ @Test func aMissingExecutableIsRefused() {
+ #expect(throws: WatchdogSpawnError.self) {
+ try ClosedLidWatchdogSpawner.spawn(
+ ClosedLidWatchdogLaunch(
+ pid: 1, marker: URL(fileURLWithPath: "/tmp/none.json"), generation: UUID()),
+ executable: URL(fileURLWithPath: "/nonexistent/dilated"))
+ }
+ }
+}
diff --git a/Tests/DilatedUITests/LocalClosedLidTransportTests.swift b/Tests/DilatedUITests/LocalClosedLidTransportTests.swift
index e42a3f6..3f8a0f2 100644
--- a/Tests/DilatedUITests/LocalClosedLidTransportTests.swift
+++ b/Tests/DilatedUITests/LocalClosedLidTransportTests.swift
@@ -57,7 +57,8 @@ import Testing
}
@Test func deniedPermissionOnDisableStaysFailedAndRetriesEachTick() async throws {
- let fixture = await LocalFixture()
+ let sleeps = SleepLog()
+ let fixture = await LocalFixture(sleeps: sleeps)
let controller = fixture.controller()
defer { controller.invalidate() }
try await controller.enable(policy: .init())
@@ -66,20 +67,35 @@ import Testing
#expect(!controller.active)
#expect(await fixture.runner.readback().confirmedEnabled)
let attempts = await fixture.runner.disableAttempts
- await waitUntil { await fixture.runner.disableAttempts > attempts + 1 }
+ await waitUntil { await fixture.runner.disableAttempts > attempts + 6 }
#expect(await fixture.runner.readback().confirmedEnabled)
+ let seconds = sleeps.values.map { Int($0.components.seconds) }
+ let backoffSteps = Array(seconds.drop { $0 != 1 })
+ #expect(backoffSteps.starts(with: [1, 2, 4, 8, 16, 30, 30]))
+ #expect(backoffSteps.allSatisfy { $0 <= 30 })
await fixture.runner.allow()
await waitUntil { await fixture.runner.readback().confirmedDisabled }
+ await waitUntil { sleeps.values.last == .milliseconds(20) }
+ }
+
+ @Test func backoffDoublesToTheCapAndResetsOnRequest() {
+ let backoff = RetryBackoff(initial: .seconds(1), cap: .seconds(30))
+ let steps = (0..<8).map { _ in Int(backoff.next().components.seconds) }
+ #expect(steps == [1, 2, 4, 8, 16, 30, 30, 30])
+ backoff.reset()
+ #expect(backoff.next() == .seconds(1))
}
@Test func theFlagDefaultsOffAndKeepsTheHelperTransport() {
let defaults = UserDefaults(suiteName: "local-lid-\(UUID().uuidString)")!
#expect(defaults.bool(forKey: LocalClosedLidTransport.defaultsKey) == false)
- let off = ClosedLidController.configured(defaults: defaults)
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "local-lid-\(UUID().uuidString)")
+ let off = ClosedLidController.configured(defaults: defaults, stateDirectory: directory)
#expect(!off.hasLease)
#expect(!off.usesLocalTransport)
defaults.set(true, forKey: LocalClosedLidTransport.defaultsKey)
- let on = ClosedLidController.configured(defaults: defaults)
+ let on = ClosedLidController.configured(defaults: defaults, stateDirectory: directory)
defer { on.invalidate() }
#expect(on.usesLocalTransport)
}
@@ -96,18 +112,30 @@ private func waitUntil(_ condition: @escaping @Sendable () async -> Bool) async
Issue.record("condition not met in time")
}
+private final class SleepLog: @unchecked Sendable {
+ private let lock = NSLock()
+ private var log: [Duration] = []
+ var values: [Duration] { lock.withLock { log } }
+ func record(_ duration: Duration) { lock.withLock { log.append(duration) } }
+}
+
@MainActor private struct LocalFixture {
let runner = FakeSudoRunner()
let inputs = LocalInputs()
let clock = LocalClock()
let transport: LocalClosedLidTransport
- init() async {
+ init(sleeps: SleepLog? = nil) async {
let runner = runner
let inputs = inputs
let clock = clock
transport = LocalClosedLidTransport(
power: SerialPowerDriver(runner: runner, readback: { await runner.readback() }),
- environment: { inputs.read() }, clock: { clock.now() }, tickInterval: .milliseconds(20))
+ environment: { inputs.read() }, clock: { clock.now() }, tickInterval: .milliseconds(20),
+ sleep: { duration in
+ guard let sleeps else { return try await Task.sleep(for: duration) }
+ sleeps.record(duration)
+ try await Task.sleep(for: .milliseconds(5))
+ })
await waitUntil { await runner.calls.contains(.setSleepDisabled(false)) }
try? await Task.sleep(for: .milliseconds(60))
}
diff --git a/website/dist/assets/social-card.png b/website/dist/assets/social-card.png
new file mode 100644
index 0000000..f7d6c25
Binary files /dev/null and b/website/dist/assets/social-card.png differ
diff --git a/website/dist/index.html b/website/dist/index.html
index d2c9035..b57dfee 100644
--- a/website/dist/index.html
+++ b/website/dist/index.html
@@ -10,6 +10,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/website/dist/llms.txt b/website/dist/llms.txt
new file mode 100644
index 0000000..3177a71
--- /dev/null
+++ b/website/dist/llms.txt
@@ -0,0 +1,22 @@
+# Dilated
+
+> Dilated is a free, MIT-licensed menu bar app that keeps a Mac awake for a set time or until you stop it. It is a public beta for macOS 26 or later on Apple Silicon, from CodesWhat. It also has a command line tool, URL automation and AppleScript, and an experimental closed-lid mode that uses a privileged helper.
+
+Dilated is distributed as a notarized Developer ID build outside the Mac App Store. Install it from the website download or with Homebrew: `brew install --cask codeswhat/tap/dilated`.
+
+## Get it
+
+- [Download the latest build](https://dilated.codeswhat.com/download/file): signed, notarized DMG for Apple Silicon
+- [Installation and removal](https://github.com/CodesWhat/dilated/blob/main/docs/INSTALLATION.md): Homebrew and DMG install, checksums, uninstall
+- [Releases](https://github.com/CodesWhat/dilated/releases): release notes, DMG, ZIP, source archive and checksums
+
+## Use it
+
+- [Automation](https://github.com/CodesWhat/dilated/blob/main/docs/AUTOMATION.md): command line, URL scheme and AppleScript
+- [Product page](https://dilated.codeswhat.com/): what it does, with an interactive menu preview
+
+## Source
+
+- [GitHub repository](https://github.com/CodesWhat/dilated): source code and issues
+- [Architecture](https://github.com/CodesWhat/dilated/blob/main/docs/ARCHITECTURE.md): how the app and helper fit together
+- [License](https://github.com/CodesWhat/dilated/blob/main/LICENSE): MIT
diff --git a/website/dist/robots.txt b/website/dist/robots.txt
new file mode 100644
index 0000000..a6a906c
--- /dev/null
+++ b/website/dist/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://dilated.codeswhat.com/sitemap.xml
diff --git a/website/dist/sitemap.xml b/website/dist/sitemap.xml
new file mode 100644
index 0000000..787074e
--- /dev/null
+++ b/website/dist/sitemap.xml
@@ -0,0 +1,6 @@
+
+
+
+ https://dilated.codeswhat.com/
+
+
diff --git a/website/e2e/serve.mjs b/website/e2e/serve.mjs
index 2bc9e4f..7f920f9 100644
--- a/website/e2e/serve.mjs
+++ b/website/e2e/serve.mjs
@@ -22,6 +22,8 @@ const types = {
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.txt': 'text/plain; charset=utf-8',
+ '.xml': 'application/xml; charset=utf-8',
+ '.png': 'image/png',
};
const server = createServer(async (request, response) => {
diff --git a/website/e2e/site.spec.mjs b/website/e2e/site.spec.mjs
index b8ba4b0..af20dca 100644
--- a/website/e2e/site.spec.mjs
+++ b/website/e2e/site.spec.mjs
@@ -388,3 +388,16 @@ test.describe('readability and usability', () => {
expect(alt.trim().split(/\s+/).length).toBeLessThanOrEqual(8);
});
});
+
+test.describe('discoverability', () => {
+ test('structured data is inert under the CSP and the crawl files are served', async ({ page, request, open }) => {
+ await open('/', { release: releases.missing });
+ const raw = await page.locator('script[type="application/ld+json"]').first().textContent();
+ expect(JSON.parse(raw)['@type']).toBe('SoftwareApplication');
+ for (const [path, type] of [['/robots.txt', /text\/plain/], ['/sitemap.xml', /xml/], ['/llms.txt', /text\/plain/], ['/assets/social-card.png', /image\/png/]]) {
+ const response = await request.get(path);
+ expect(response.status(), path).toBe(200);
+ expect(response.headers()['content-type'], path).toMatch(type);
+ }
+ });
+});
diff --git a/website/social/card.html b/website/social/card.html
new file mode 100644
index 0000000..c612394
--- /dev/null
+++ b/website/social/card.html
@@ -0,0 +1,18 @@
+
+
+
+dilated
+Keep your Mac awake.
+FREE MENU BAR APP FOR MACOS 26+ · APPLE SILICON · PUBLIC BETA
+NOT DONE YET.
diff --git a/website/social/render-card.mjs b/website/social/render-card.mjs
new file mode 100644
index 0000000..5c778d4
--- /dev/null
+++ b/website/social/render-card.mjs
@@ -0,0 +1,14 @@
+// Renders social/card.html to dist/assets/social-card.png (1200x630).
+// Run from website/ after `npm ci`: node social/render-card.mjs
+import { chromium } from '@playwright/test';
+import { fileURLToPath } from 'node:url';
+
+const page = fileURLToPath(new URL('./card.html', import.meta.url));
+const out = fileURLToPath(new URL('../dist/assets/social-card.png', import.meta.url));
+const browser = await chromium.launch();
+const tab = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 });
+await tab.goto(`file://${page}`);
+await tab.evaluate(() => document.fonts.ready);
+await tab.screenshot({ path: out, clip: { x: 0, y: 0, width: 1200, height: 630 } });
+await browser.close();
+console.log(`wrote ${out}`);
diff --git a/website/tests/discoverability.test.mjs b/website/tests/discoverability.test.mjs
new file mode 100644
index 0000000..ff66c0c
--- /dev/null
+++ b/website/tests/discoverability.test.mjs
@@ -0,0 +1,94 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { readFile } from 'node:fs/promises';
+
+const CANONICAL = 'https://dilated.codeswhat.com/';
+const read = name => readFile(new URL(`../dist/${name}`, import.meta.url), 'utf8');
+const html = await read('index.html');
+
+function jsonLd() {
+ const blocks = [...html.matchAll(/