From 3e68c621d60aa21cbf36c3273037f8a542efe36d Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:36:02 -0500 Subject: [PATCH 1/4] feat(closed-lid): add sudoers rule install, detect and remove, and back off permission retries (#58) ClosedLidPermission owns the fixed /private/etc/sudoers.d/dilated rule, detects notInstalled, needsRepair, blocked, ready and notAdmin, and installs or removes it through an injected admin-prompt runner. The local transport tick loop now backs off from 1s to 30s while sudo refuses, still failed and still retrying. --- Sources/DilatedCore/ClosedLidPermission.swift | 198 ++++++++++++++++++ Sources/DilatedCore/ClosedLidService.swift | 11 + .../ClosedLid/LocalClosedLidTransport.swift | 47 ++++- .../ClosedLidPermissionTests.swift | 155 ++++++++++++++ .../LocalClosedLidTransportTests.swift | 34 ++- 5 files changed, 437 insertions(+), 8 deletions(-) create mode 100644 Sources/DilatedCore/ClosedLidPermission.swift create mode 100644 Tests/DilatedCoreTests/ClosedLidPermissionTests.swift diff --git a/Sources/DilatedCore/ClosedLidPermission.swift b/Sources/DilatedCore/ClosedLidPermission.swift new file mode 100644 index 0000000..dab98e6 --- /dev/null +++ b/Sources/DilatedCore/ClosedLidPermission.swift @@ -0,0 +1,198 @@ +import Darwin +import Foundation + +public enum ClosedLidPermissionState: Equatable, Sendable { + case notInstalled, needsRepair, blocked, ready, notAdmin +} + +public struct ClosedLidFileInfo: Equatable, Sendable { + public var isRegularFile: Bool + public var owner: UInt32 + public var group: UInt32 + public var mode: UInt16 + + public init(isRegularFile: Bool, owner: UInt32, group: UInt32, mode: UInt16) { + self.isRegularFile = isRegularFile + self.owner = owner + self.group = group + self.mode = mode + } +} + +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) + static let probeArguments = ["-n", "-l", "/usr/bin/pmset", "-a", "disablesleep", "1"] + + 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 0444 \"$tmp\" \"$stage\"", + "/bin/mv -f \"$stage\" \(rulePath)", + "/usr/sbin/visudo -c || { /bin/rm -f \(rulePath); exit 1; }", + ].joined(separator: "; ") + + static let removeScript = [ + "set -eu", + "/bin/rm -f \(rulePath)", + "/usr/sbin/visudo -c", + ].joined(separator: "; ") + + /// 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" + } + + /// AppleScript error -128 is the user dismissing the admin prompt. + static func outcome(errorNumber: Int?, message: String?) -> PrivilegedScriptOutcome { + if errorNumber == -128 { return .cancelled } + return .failed(message ?? "The administrator command failed.") + } + + 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 = AppleScriptAdminRunner() + ) { + 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 .notInstalled } + guard info.isRegularFile, info.owner == 0, info.group == 0, info.mode == 0o444, + seams.readFile(Self.rulePath) == Self.ruleContent + else { 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) + } + + 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) + } + return ClosedLidFileInfo( + isRegularFile: info.st_mode & S_IFMT == S_IFREG, owner: info.st_uid, group: info.st_gid, + mode: info.st_mode & 0o7777) + } + + static func liveProbe() async -> Bool { + guard + let result = try? await BoundedProcess.run( + executable: URL(fileURLWithPath: SudoPowerRunner.sudoPath), arguments: probeArguments, + timeout: 3, environment: [:]) + else { return false } + return !result.timedOut && result.status == 0 + } + + 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. +public struct AppleScriptAdminRunner: PrivilegedScriptRunning { + public init() {} + + public func run(script source: String) async -> PrivilegedScriptOutcome { + await MainActor.run { + var error: NSDictionary? + guard let script = NSAppleScript(source: source) else { + return .failed("The administrator command couldn't be prepared.") + } + _ = script.executeAndReturnError(&error) + guard let error else { return .succeeded } + return ClosedLidPermission.outcome( + errorNumber: (error[NSAppleScript.errorNumber] as? NSNumber)?.intValue, + message: error[NSAppleScript.errorMessage] as? String) + } + } +} 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/DilatedUI/ClosedLid/LocalClosedLidTransport.swift b/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift index 5b85393..c80ab5e 100644 --- a/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift +++ b/Sources/DilatedUI/ClosedLid/LocalClosedLidTransport.swift @@ -12,12 +12,15 @@ import Foundation private let service: ClosedLidService private let owner = UUID() private let ticker: TickLoop + private let backoff: RetryBackoff public init( 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,10 +32,12 @@ import Foundation power: power, environment: environment, clock: resolved, buildIdentity: Self.buildIdentity) self.service = service - ticker = TickLoop(service: service, interval: tickInterval) + self.backoff = backoff + 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) } @@ -44,15 +49,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 + } } } } diff --git a/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift b/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift new file mode 100644 index 0000000..89990c1 --- /dev/null +++ b/Tests/DilatedCoreTests/ClosedLidPermissionTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing + +@testable import DilatedCore + +struct ClosedLidPermissionTests { + private static let good = ClosedLidFileInfo( + isRegularFile: true, owner: 0, group: 0, mode: 0o444) + + private func permission( + info: ClosedLidFileInfo? = good, content: Data? = ClosedLidPermission.ruleContent, + 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).state() == .notInstalled) + #expect(await permission(content: Data("x\n".utf8)).state() == .needsRepair) + #expect(await permission(content: nil).state() == .needsRepair) + 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 = 0o644 + #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) + } + + @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 0444")) + #expect(install.contains("/bin/mv -f \"$stage\" /private/etc/sudoers.d/dilated")) + #expect( + install.contains( + "/usr/sbin/visudo -c || { /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("/usr/sbin/visudo -c")) + 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 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, 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(errorNumber: -128, message: "User canceled.") == .cancelled) + #expect(ClosedLidPermission.outcome(errorNumber: 1, message: "boom") == .failed("boom")) + #expect( + ClosedLidPermission.outcome(errorNumber: nil, message: nil) + == .failed("The administrator command failed.")) + } + + @Test func aFailedScriptReportsTheStateAfterward() async { + let runner = RecordingRunner(outcome: .failed("visudo said no")) + let outcome = await permission(info: nil, 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 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/DilatedUITests/LocalClosedLidTransportTests.swift b/Tests/DilatedUITests/LocalClosedLidTransportTests.swift index e42a3f6..0e7eeb3 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,10 +67,23 @@ 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() { @@ -96,18 +110,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)) } From 05a8b53a3b6b3f3e77283456e38c8eff693b519e Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:43:53 -0500 Subject: [PATCH 2/4] docs(readme): match the CodesWhat product README layout (#61) * docs(readme): align with the CodesWhat product README shape * docs(readme): drop the empty screenshots section and tighten the intro --- README.md | 299 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 177 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index 07c51dc..1a694f2 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,177 @@ -# Dilated - -[![Coverage](https://codecov.io/gh/CodesWhat/dilated/branch/main/graph/badge.svg)](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 app icon + +

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. + +
+ +

+ Version 1.0.0-beta.8 + macOS 26 or later + Apple Silicon + Latest release + Coverage + License MIT +

+ +
+ +> [!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

+ +
+ + + + + Star history chart for CodesWhat/dilated + + +
+ +
+ +

CodesWhat Ecosystem

+ + + + + + + + +
ToolRole
dilatedKeeps your Mac awake. Menu bar app with triggers and a CLI
mouthtypeLocal dictation for Mac. Hold Fn, speak, release to paste
drydockContainer update monitoring, web UI and notification engine
portwingRemote Docker agent, secure socket-level access from Drydock or standalone
sockguardDocker 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. + +
+ Back to top +
From 3ffa6a4776ca3f3bf8ce08f0c102c816207166af Mon Sep 17 00:00:00 2001 From: Scott Benson <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:43:58 -0500 Subject: [PATCH 3/4] feat(website): add structured data, crawl files and social preview card (#62) --- website/dist/assets/social-card.png | Bin 0 -> 47942 bytes website/dist/index.html | 37 ++++++++++ website/dist/llms.txt | 22 ++++++ website/dist/robots.txt | 4 ++ website/dist/sitemap.xml | 6 ++ website/e2e/serve.mjs | 2 + website/e2e/site.spec.mjs | 13 ++++ website/social/card.html | 18 +++++ website/social/render-card.mjs | 14 ++++ website/tests/discoverability.test.mjs | 94 +++++++++++++++++++++++++ website/vercel.json | 21 ++++++ 11 files changed, 231 insertions(+) create mode 100644 website/dist/assets/social-card.png create mode 100644 website/dist/llms.txt create mode 100644 website/dist/robots.txt create mode 100644 website/dist/sitemap.xml create mode 100644 website/social/card.html create mode 100644 website/social/render-card.mjs create mode 100644 website/tests/discoverability.test.mjs diff --git a/website/dist/assets/social-card.png b/website/dist/assets/social-card.png new file mode 100644 index 0000000000000000000000000000000000000000..f7d6c2533e81204c2e911c1e2188e4458de6e4e0 GIT binary patch literal 47942 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�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 + + + + + + + + + + + + + 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(/