From 7772aac8d705c185149413702334369f69076e54 Mon Sep 17 00:00:00 2001 From: Farnood Date: Sun, 7 Jun 2026 16:53:37 -0400 Subject: [PATCH 1/2] Add functional safety cutoffs and lid display sleep Co-authored-by: Cursor --- .github/workflows/ci.yml | 16 +- AgentMonitor.swift | 595 +++++++++++++++++++++++ App.swift | 644 ++++++++++++++++++------- AppLogger.swift | 88 ++++ CHANGELOG.md | 45 ++ CONTEXT.md | 23 + ConnectivityMonitor.swift | 39 ++ LidMonitor.swift | 110 +++++ PowerController.swift | 69 +++ README.de.md | 4 +- README.es.md | 4 +- README.fr.md | 4 +- README.ja.md | 4 +- README.md | 66 +-- README.zh-CN.md | 4 +- SECURITY.md | 51 +- ShellRunner.swift | 54 +++ build.sh | 36 +- docs/AUDIT.md | 39 +- docs/LAUNCH.md | 3 +- docs/LISTINGS.md | 4 +- docs/adr/0001-local-agent-detection.md | 3 + grant.sh | 39 +- install.sh | 16 +- reset-agent-setup.sh | 109 +++++ sleepless.sudoers.template | 6 +- uninstall.sh | 22 +- 27 files changed, 1812 insertions(+), 285 deletions(-) create mode 100644 AgentMonitor.swift create mode 100644 AppLogger.swift create mode 100644 CONTEXT.md create mode 100644 ConnectivityMonitor.swift create mode 100644 LidMonitor.swift create mode 100644 PowerController.swift create mode 100644 ShellRunner.swift create mode 100644 docs/adr/0001-local-agent-detection.md create mode 100755 reset-agent-setup.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 417ccb2..8794f88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,13 +22,23 @@ jobs: swiftc --version echo "SDK: $(xcrun --sdk macosx --show-sdk-version 2>/dev/null || echo n/a)" - - name: Compile App.swift (zero-warning gate) + - name: Compile Swift sources (zero-warning gate) run: | set -euo pipefail # Local + release builds target arm64-apple-macos26.0. CI compiles against the # runner's SDK (which may predate macOS 26) purely as a clean-compile smoke test, # so it does not force the 26.0 deployment target here. - swiftc -O -parse-as-library -framework AppKit App.swift -o /tmp/Sleepless 2> build.log \ + swiftc -O -parse-as-library \ + -framework AppKit -framework ServiceManagement -framework Network \ + -framework IOKit \ + AppLogger.swift \ + ShellRunner.swift \ + PowerController.swift \ + AgentMonitor.swift \ + ConnectivityMonitor.swift \ + LidMonitor.swift \ + App.swift \ + -o /tmp/Sleepless 2> build.log \ || { echo "::group::swiftc output"; cat build.log; echo "::endgroup::"; exit 1; } cat build.log if grep -q "warning:" build.log; then @@ -47,6 +57,6 @@ jobs: - name: Upload app artifact uses: actions/upload-artifact@v4 with: - name: Sleepless-app + name: Sleepless-Agents-app path: dist/Sleepless.app if-no-files-found: error diff --git a/AgentMonitor.swift b/AgentMonitor.swift new file mode 100644 index 0000000..995af70 --- /dev/null +++ b/AgentMonitor.swift @@ -0,0 +1,595 @@ +import AppKit +import Darwin +import Foundation + +enum AgentID: String, CaseIterable { + case claude + case codex + case cursor + + var displayName: String { + switch self { + case .claude: return "Claude Code" + case .codex: return "Codex" + case .cursor: return "Cursor" + } + } + + var commandName: String { + switch self { + case .claude: return "claude" + case .codex: return "codex" + case .cursor: return "cursor" + } + } +} + +enum AgentStatus: String { + case active = "Active" + case idle = "Idle" + case setupNeeded = "Setup needed" +} + +struct AgentToolSnapshot { + let id: AgentID + let displayName: String + let status: AgentStatus + let detail: String +} + +struct AgentSetupResult { + let ok: Bool + let message: String + let logURL: URL +} + +final class AgentMonitor { + private let heartbeatFreshness: TimeInterval = 120 + private let heartbeatScriptVersion = "4" + private let queue = DispatchQueue(label: "Sleepless.AgentMonitor", qos: .utility) + private let fileManager = FileManager.default + private var cachedCLIPaths: [String: String] = [:] + private var cachedCursorInstalled: Bool? + + func snapshotsAsync(completion: @escaping ([AgentToolSnapshot]) -> Void) { + queue.async { + let snapshots = self.snapshots() + DispatchQueue.main.async { + completion(snapshots) + } + } + } + + private func snapshots() -> [AgentToolSnapshot] { + let processes = processList() + return AgentID.allCases.compactMap { snapshot(for: $0, processes: processes) } + } + + func installIntegration(for id: AgentID) -> AgentSetupResult { + AppLogger.info("agent_setup_start", ["tool": id.rawValue]) + do { + let script = try writeHeartbeatHelper() + let installed: Bool + switch id { + case .claude: + installed = try installClaudeHooks(script: script) + case .codex: + installed = try installCodexHooks(script: script) + case .cursor: + installed = try installCursorHooks(script: script) + } + guard installed else { + let message = "Hook command was written but could not be verified in the tool config." + AppLogger.error("agent_setup_verify_failed", ["tool": id.rawValue]) + return AgentSetupResult(ok: false, message: message, logURL: AppLogger.logURL) + } + let readme = heartbeatDirectory.appendingPathComponent("README.txt") + let text = """ + Sleepless heartbeat helper + + Helper: + \(script.path) + + Configure an app-wide hook in the agent tool to run this helper with the tool id: + \(script.path) \(id.rawValue) + + Sleepless reads only these heartbeat files to decide whether local agent work is active. + """ + try text.write(to: readme, atomically: true, encoding: .utf8) + AppLogger.info("agent_setup_success", ["tool": id.rawValue]) + return AgentSetupResult(ok: true, message: "\(id.displayName) detector set up.", logURL: AppLogger.logURL) + } catch { + let nsError = error as NSError + let message = setupErrorMessage(error) + AppLogger.error("agent_setup_failed", [ + "tool": id.rawValue, + "domain": nsError.domain, + "code": String(nsError.code), + "reason": message + ]) + NSLog("Sleepless: agent integration setup failed: %@", message) + return AgentSetupResult(ok: false, message: message, logURL: AppLogger.logURL) + } + } + + private func snapshot(for id: AgentID, processes: [ProcessSnapshot]) -> AgentToolSnapshot? { + switch id { + case .claude: + guard let path = resolveCLI(command: "claude", extraPaths: [ + "\(home)/.local/bin/claude", + "\(home)/.claude/local/bin/claude" + ]) else { return nil } + let status = statusFromIntegration(for: id) + return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: path) + + case .codex: + guard let path = resolveCLI(command: "codex", extraPaths: [ + "\(home)/.local/bin/codex" + ]) else { return nil } + let status = statusFromIntegration(for: id) + return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: path) + + case .cursor: + guard cursorInstalled() else { return nil } + if heartbeatIsFresh(for: id) || cursorAgentProcessMatches(processes) { + return AgentToolSnapshot(id: id, displayName: id.displayName, status: .active, detail: "Local agent signal") + } + let status: AgentStatus = integrationConfigured(for: id) ? .idle : .setupNeeded + return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: "Cursor app installed") + } + } + + private func statusFromIntegration(for id: AgentID) -> AgentStatus { + guard integrationConfigured(for: id) else { return .setupNeeded } + return heartbeatIsFresh(for: id) ? .active : .idle + } + + private func resolveCLI(command: String, extraPaths: [String]) -> String? { + if let cached = cachedCLIPaths[command] { return cached } + + let candidates = [ + "/opt/homebrew/bin/\(command)", + "/usr/local/bin/\(command)", + "/usr/bin/\(command)", + "/bin/\(command)" + ] + extraPaths + + for path in candidates where isExecutable(path) && validates(commandAt: path) { + cachedCLIPaths[command] = path + return path + } + + let shell = ShellRunner.run("/bin/zsh", ["-lc", "command -v \(command)"], timeout: 2) + let path = shell.out.trimmingCharacters(in: .whitespacesAndNewlines) + guard shell.exit == 0, !path.isEmpty, isExecutable(path), validates(commandAt: path) else { + return nil + } + cachedCLIPaths[command] = path + return path + } + + private func validates(commandAt path: String) -> Bool { + ShellRunner.run(path, ["--version"], timeout: 2).exit == 0 + } + + private func isExecutable(_ path: String) -> Bool { + fileManager.isExecutableFile(atPath: NSString(string: path).expandingTildeInPath) + } + + private func cursorInstalled() -> Bool { + if let cachedCursorInstalled { return cachedCursorInstalled } + let workspace = NSWorkspace.shared + let installed = workspace.urlForApplication(withBundleIdentifier: "com.todesktop.230313mzl4w4u92") != nil + || workspace.urlForApplication(withBundleIdentifier: "co.anysphere.cursor.nightly") != nil + cachedCursorInstalled = installed + return installed + } + + private func cursorAgentProcessMatches(_ processes: [ProcessSnapshot]) -> Bool { + processes.contains { proc in + guard proc.uid == getuid() else { return false } + return proc.command == "cursor-agent" + || proc.command == "cursor-agent-worker" + } + } + + private func heartbeatIsFresh(for id: AgentID) -> Bool { + let url = heartbeatURL(for: id) + guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), + let modified = attrs[.modificationDate] as? Date else { + return false + } + guard heartbeatFile(at: url, belongsTo: id) else { return false } + return Date().timeIntervalSince(modified) <= heartbeatFreshness + } + + private func heartbeatFile(at url: URL, belongsTo id: AgentID) -> Bool { + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) else { + return false + } + var fields: [String: String] = [:] + text.split(whereSeparator: \.isNewline).forEach { line in + let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { return } + fields[String(parts[0])] = String(parts[1]) + } + guard fields["version"] == heartbeatScriptVersion, + fields["tool"] == id.rawValue, + fields["state"] == "active", + let timestamp = fields["time"].flatMap(TimeInterval.init) else { + return false + } + if id != .cursor, heartbeatOriginIsCursor(fields["process_chain"] ?? "") { + return false + } + return Date().timeIntervalSince1970 - timestamp <= heartbeatFreshness + } + + private func heartbeatOriginIsCursor(_ processChain: String) -> Bool { + processChain + .lowercased() + .split(separator: "|") + .contains { part in + let name = part.trimmingCharacters(in: .whitespacesAndNewlines) + return name == "cursor" || name.hasPrefix("cursor ") || name.contains("/cursor") + } + } + + private func integrationConfigured(for id: AgentID) -> Bool { + do { + let root = try readJSONObject(at: configURL(for: id)) + guard let hooks = root["hooks"] as? [String: Any] else { return false } + let configured = expectedEvents(for: id, script: heartbeatDirectory.appendingPathComponent("heartbeat.sh")).allSatisfy { event in + guard let entries = hooks[event.name] as? [[String: Any]] else { return false } + return entries.contains { hookEntry($0, contains: event.command, nestedCommandSchema: usesNestedHookSchema(id)) } + } + guard configured else { return false } + if !heartbeatHelperIsCurrent() { + _ = try writeHeartbeatHelper() + } + return true + } catch { + AppLogger.error("agent_setup_structural_verify_failed", [ + "tool": id.rawValue, + "reason": error.localizedDescription + ]) + return false + } + } + + private func installClaudeHooks(script: URL) throws -> Bool { + let url = configURL(for: .claude) + AppLogger.info("agent_setup_merge_config", ["tool": AgentID.claude.rawValue, "path": url.path]) + let events = expectedEvents(for: .claude, script: script) + try mergeCommandHooks( + into: url, + events: events, + nestedCommandSchema: true, + versioned: false + ) + return integrationConfigured(for: .claude) + } + + private func installCodexHooks(script: URL) throws -> Bool { + let url = configURL(for: .codex) + AppLogger.info("agent_setup_merge_config", ["tool": AgentID.codex.rawValue, "path": url.path]) + let events = expectedEvents(for: .codex, script: script) + try mergeCommandHooks( + into: url, + events: events, + nestedCommandSchema: true, + versioned: false + ) + return integrationConfigured(for: .codex) + } + + private func installCursorHooks(script: URL) throws -> Bool { + let url = configURL(for: .cursor) + AppLogger.info("agent_setup_merge_config", ["tool": AgentID.cursor.rawValue, "path": url.path]) + let events = expectedEvents(for: .cursor, script: script) + try mergeCommandHooks( + into: url, + events: events, + nestedCommandSchema: false, + versioned: true + ) + return integrationConfigured(for: .cursor) + } + + private func mergeCommandHooks(into url: URL, events: [HookEvent], nestedCommandSchema: Bool, versioned: Bool) throws { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + var root = try readJSONObject(at: url) + if versioned, root["version"] == nil { root["version"] = 1 } + var hooks: [String: Any] + if let existing = root["hooks"] { + if let typed = existing as? [String: Any] { + hooks = typed + } else { + let backup = backupInvalidConfig(at: url) + AppLogger.error("agent_setup_invalid_hooks_shape_replaced", ["path": url.path, "backup": backup.path]) + root = versioned ? ["version": 1] : [:] + hooks = [:] + } + } else { + hooks = [:] + } + hooks = pruneSleeplessHooks(from: hooks, nestedCommandSchema: nestedCommandSchema) + + for event in events { + var entries: [[String: Any]] + if let existing = hooks[event.name] { + if let typed = existing as? [[String: Any]] { + entries = typed + } else { + let backup = backupInvalidConfig(at: url) + AppLogger.error("agent_setup_invalid_event_shape_replaced", [ + "path": url.path, + "backup": backup.path, + "event": event.name + ]) + root = versioned ? ["version": 1] : [:] + hooks = [:] + entries = [] + } + } else { + entries = [] + } + let alreadyInstalled = entries.contains { hookEntry($0, contains: event.command, nestedCommandSchema: nestedCommandSchema) } + guard !alreadyInstalled else { continue } + + if nestedCommandSchema { + var entry: [String: Any] = [ + "hooks": [ + [ + "type": "command", + "command": event.command + ] + ] + ] + if let matcher = event.matcher { entry["matcher"] = matcher } + entries.append(entry) + } else { + entries.append(["command": event.command]) + } + hooks[event.name] = entries + } + + root["hooks"] = hooks + try writeJSONObject(root, to: url) + } + + private func configURL(for id: AgentID) -> URL { + switch id { + case .claude: + return homeURL.appendingPathComponent(".claude/settings.json") + case .codex: + return homeURL.appendingPathComponent(".codex/hooks.json") + case .cursor: + return homeURL.appendingPathComponent(".cursor/hooks.json") + } + } + + private func usesNestedHookSchema(_ id: AgentID) -> Bool { + id == .claude || id == .codex + } + + private func expectedEvents(for id: AgentID, script: URL) -> [HookEvent] { + switch id { + case .claude: + return [ + HookEvent(name: "UserPromptSubmit", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: nil, state: "active"), + HookEvent(name: "PreToolUse", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: ".*", state: "active"), + HookEvent(name: "PostToolUse", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: ".*", state: "active"), + HookEvent(name: "Stop", command: heartbeatCommand(script: script, tool: .claude, state: "stop"), matcher: nil, state: "stop") + ] + case .codex: + return [ + HookEvent(name: "UserPromptSubmit", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: nil, state: "active"), + HookEvent(name: "PreToolUse", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: ".*", state: "active"), + HookEvent(name: "PostToolUse", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: ".*", state: "active"), + HookEvent(name: "Stop", command: heartbeatCommand(script: script, tool: .codex, state: "stop"), matcher: nil, state: "stop") + ] + case .cursor: + return [ + HookEvent(name: "beforeSubmitPrompt", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"), + HookEvent(name: "preToolUse", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"), + HookEvent(name: "postToolUse", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"), + HookEvent(name: "stop", command: heartbeatCommand(script: script, tool: .cursor, state: "stop"), matcher: nil, state: "stop") + ] + } + } + + private func hookEntry(_ entry: [String: Any], contains command: String, nestedCommandSchema: Bool) -> Bool { + if nestedCommandSchema { + guard let hooks = entry["hooks"] as? [[String: Any]] else { return false } + return hooks.contains { ($0["command"] as? String) == command } + } + return (entry["command"] as? String) == command + } + + private func pruneSleeplessHooks(from hooks: [String: Any], nestedCommandSchema: Bool) -> [String: Any] { + var pruned = hooks + for (event, value) in hooks { + guard let entries = value as? [[String: Any]] else { continue } + let kept = entries.compactMap { pruneSleeplessHookEntry($0, nestedCommandSchema: nestedCommandSchema) } + if kept.isEmpty { + pruned.removeValue(forKey: event) + } else { + pruned[event] = kept + } + } + return pruned + } + + private func pruneSleeplessHookEntry(_ entry: [String: Any], nestedCommandSchema: Bool) -> [String: Any]? { + if !nestedCommandSchema { + return commandOwnedBySleepless(entry["command"]) ? nil : entry + } + + guard let hooks = entry["hooks"] as? [[String: Any]] else { return entry } + let keptHooks = hooks.filter { !commandOwnedBySleepless($0["command"]) } + guard keptHooks.count != hooks.count else { return entry } + guard !keptHooks.isEmpty else { return nil } + var updated = entry + updated["hooks"] = keptHooks + return updated + } + + private func commandOwnedBySleepless(_ command: Any?) -> Bool { + guard let command = command as? String else { return false } + return command.contains(".sleepless/agents/heartbeat.sh") + } + + private func readJSONObject(at url: URL) throws -> [String: Any] { + guard fileManager.fileExists(atPath: url.path) else { return [:] } + let data = try Data(contentsOf: url) + guard !data.isEmpty else { return [:] } + let parsed: Any + do { + parsed = try JSONSerialization.jsonObject(with: data) + } catch { + let backup = backupInvalidConfig(at: url) + AppLogger.error("agent_setup_invalid_json_replaced", ["path": url.path, "backup": backup.path]) + return [:] + } + guard let object = parsed as? [String: Any] else { + let backup = backupInvalidConfig(at: url) + AppLogger.error("agent_setup_invalid_top_level_replaced", ["path": url.path, "backup": backup.path]) + return [:] + } + return object + } + + private func backupInvalidConfig(at url: URL) -> URL { + let stamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + let backup = url.deletingLastPathComponent() + .appendingPathComponent(url.lastPathComponent + ".sleepless-backup-\(stamp)") + try? fileManager.copyItem(at: url, to: backup) + return backup + } + + private func writeJSONObject(_ object: [String: Any], to url: URL) throws { + let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: url, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + + private func heartbeatCommand(for id: AgentID, state: String) -> String { + heartbeatCommand(script: heartbeatDirectory.appendingPathComponent("heartbeat.sh"), tool: id, state: state) + } + + private func heartbeatCommand(script: URL, tool: AgentID, state: String) -> String { + "\(shellQuoted(script.path)) \(tool.rawValue) \(state)" + } + + private func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private func processList() -> [ProcessSnapshot] { + let out = ShellRunner.capture("/bin/ps", ["-axo", "pid=,uid=,comm="], timeout: 2) + return out.split(separator: "\n").compactMap { line in + let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true) + guard parts.count == 3, let pid = Int32(parts[0]), let uid = uid_t(String(parts[1])) else { return nil } + return ProcessSnapshot( + pid: pid, + uid: uid, + executablePath: String(parts[2]) + ) + } + } + + private var home: String { homeURL.path } + private var homeURL: URL { fileManager.homeDirectoryForCurrentUser } + private var heartbeatDirectory: URL { homeURL.appendingPathComponent(".sleepless/agents", isDirectory: true) } + private func heartbeatURL(for id: AgentID) -> URL { heartbeatDirectory.appendingPathComponent("\(id.rawValue).heartbeat") } + + private func writeHeartbeatHelper() throws -> URL { + try fileManager.createDirectory(at: heartbeatDirectory, withIntermediateDirectories: true) + let script = heartbeatDirectory.appendingPathComponent("heartbeat.sh") + AppLogger.info("agent_setup_write_helper", ["path": script.path, "version": heartbeatScriptVersion]) + try heartbeatHelperBody.write(to: script, atomically: true, encoding: .utf8) + try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path) + AgentID.allCases.forEach { try? fileManager.removeItem(at: heartbeatURL(for: $0)) } + return script + } + + private func heartbeatHelperIsCurrent() -> Bool { + let script = heartbeatDirectory.appendingPathComponent("heartbeat.sh") + guard fileManager.isExecutableFile(atPath: script.path), + let data = try? Data(contentsOf: script), + let text = String(data: data, encoding: .utf8) else { + return false + } + return text.contains("SLEEPLESS_HEARTBEAT_VERSION=\(heartbeatScriptVersion)") + } + + private var heartbeatHelperBody: String { + """ + #!/bin/zsh + set -eu + SLEEPLESS_HEARTBEAT_VERSION=\(heartbeatScriptVersion) + tool="${1:-unknown}" + state="${2:-active}" + case "$tool" in + claude|codex|cursor) ;; + *) exit 0 ;; + esac + dir="$HOME/.sleepless/agents" + mkdir -p "$dir" + process_chain="" + pid="$$" + while [ -n "$pid" ] && [ "$pid" != "0" ]; do + comm="$(/bin/ps -o comm= -p "$pid" 2>/dev/null || true)" + [ -n "$comm" ] && process_chain="${process_chain:+$process_chain|}$comm" + pid="$(/bin/ps -o ppid= -p "$pid" 2>/dev/null | /usr/bin/tr -d ' ' || true)" + done + write_heartbeat() { + tmp="$dir/$tool.heartbeat.tmp" + { + /bin/echo "version=$SLEEPLESS_HEARTBEAT_VERSION" + /bin/echo "tool=$tool" + /bin/echo "state=$state" + /bin/echo "time=$(/bin/date +%s)" + /bin/echo "process_chain=$process_chain" + } > "$tmp" + /bin/mv "$tmp" "$dir/$tool.heartbeat" + } + case "$state" in + stop) state="stop"; write_heartbeat ;; + *) state="active"; write_heartbeat ;; + esac + """ + } + + private func setupErrorMessage(_ error: Error) -> String { + let nsError = error as NSError + switch nsError.code { + case NSFileWriteNoPermissionError, NSFileReadNoPermissionError: + return "Permission denied while writing the agent hook config." + case NSFileWriteFileExistsError: + return "A file already exists where a setup directory is needed." + default: + return error.localizedDescription + } + } +} + +private struct ProcessSnapshot { + let pid: Int32 + let uid: uid_t + let executablePath: String + + var command: String { + URL(fileURLWithPath: executablePath).lastPathComponent + } +} + +private struct HookEvent { + let name: String + let command: String + let matcher: String? + let state: String +} diff --git a/App.swift b/App.swift index 4c4d254..89ca240 100644 --- a/App.swift +++ b/App.swift @@ -11,40 +11,60 @@ // disablesleep is runtime-only and resets to 0 on reboot, and that reset is a // deliberate safety feature; the app does NOT auto re-arm. // -// UI: clicking the menu-bar coffee cup opens a small native popover with an NSSwitch -// toggle (the System-Settings control), a state caption, an auto-off timer, the -// battery-floor slider, a Launch-at-login switch, and Quit. The menu-bar glyph also -// shows state at a glance. +// SYSTEM vs DISPLAY sleep: disablesleep blocks only SYSTEM sleep, never DISPLAY sleep +// (the two are independent power domains). So the Mac keeps running headless while the +// screen is free to turn off. macOS normally blanks the internal panel when the lid is +// shut; to make that guaranteed and immediate, LidMonitor watches the clamshell sensor +// and, while we're keeping the Mac awake, issues `pmset displaysleepnow` the moment the +// lid closes — an action verb that needs NO root. +// Result: lid closed = display off (no battery drain) + system active in the background. // -// The coffee-cup metaphor is literal: an EMPTY cup means the Mac sleeps normally, a -// FULL cup means it is being kept awake (caffeinated), and a full cup with a small -// dot means it is awake on battery with the auto-off safety net live. +// UI: clicking the menu-bar glyph opens a small native popover with an NSSwitch +// toggle (the System-Settings control), a state caption, auto-off controls, monitored +// agent status, a Low-Power-Mode auto-off switch, the battery-floor slider, a +// Launch-at-login switch, and Quit. The +// menu-bar glyph also shows state at a glance. // -// Three small, fail-safe features layer on top, none of which adds a daemon or +// The menu-bar mark uses the existing coffee-cup states: no steam means the Mac sleeps +// normally, steam means it is being kept awake, and the dot marks battery/auto-off state. +// +// Several fail-safe features layer on top, none of which adds a daemon or // persists OS state (so "reboot resets it" still holds): // 1. Auto-off timer (1h / 2h) — a one-shot in-memory Timer that flips sleep back // on when it fires. Dies on quit; nothing survives a reboot. // 2. Launch at login (SMAppService.mainApp) — OFF by default. The app always // launches reading the TRUE system state, so a login launch can never // re-enable disablesleep on its own. -// 3. Low-Power-Mode auto-off — on battery, if Low Power Mode is on, Sleepless -// turns itself off. Same shape as the battery floor, evaluated on the same tick. +// 3. Low-Power-Mode auto-off (user toggle, ON by default) — on battery, if Low Power +// Mode is on, Sleepless turns itself off. Evaluated on the same tick as the battery +// floor; a deliberate turn-on overrides it for the session (the hard floor never). +// 4. Agent/internet auto-off — opt-in safety cutoffs with a grace period; they only +// turn Sleepless off and never re-arm keep-awake. // // Build (mirrors Nexus.app): Command Line Tools `swiftc`, NO Xcode project. // swiftc -O -parse-as-library -target arm64-apple-macos26.0 -framework AppKit \ -// -framework ServiceManagement +// -framework ServiceManagement -framework Network ... // File MUST be named App.swift and compiled -parse-as-library so the // @main enum + @MainActor static main() entry is Swift-6 isolation-safe. import AppKit +import Darwin import ServiceManagement // MARK: - Tunables -private let pollInterval: TimeInterval = 60 +private let pollInterval: TimeInterval = 30 +private let visibleAgentRefreshInterval: TimeInterval = 2 +private let cutoffGraceInterval: TimeInterval = 120 // Battery-floor config (user-adjustable via the popover slider; persisted in UserDefaults). private let floorKey = "batteryFloorPercent" +private let agentAutoOffKey = "agentAutoOffEnabled" +private let internetAutoOffKey = "internetAutoOffEnabled" +private let lpmAutoOffKey = "lowPowerAutoOffEnabled" private let floorDefault = 15 private let floorMin = 5 private let floorMax = 50 +private let appDisplayName = "Sleepless" +private let sudoersDropInPath = "/etc/sudoers.d/sleepless-disablesleep" +private let sudoersCommandGrant = "ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1" // MARK: - Menu-bar coffee glyph (native SF Symbols, MONOCHROME template — state by SHAPE) // macOS convention: a menu-bar extra is a template image (no colour) so it adapts to light/dark @@ -54,8 +74,8 @@ private let floorMax = 50 // OFF (sleeps normally) = cup.and.saucer cup resting on its saucer, NO steam (cold/asleep) // ON (kept awake, on power) = cup.and.heat.waves.fill hot cup with rising steam (awake) // ARMED (kept awake, on battery) = cup.and.heat.waves.fill + a small dot (awake, safety net live) -// The no-steam → steam change reads instantly even at 16 px; the armed dot is the only extra -// mark. All template (monochrome) — SF Symbols only, no hand-drawn paths. +// The no-steam -> steam change reads instantly even at 16 px; the armed dot is the only extra +// mark. All template (monochrome) -- SF Symbols only, no hand-drawn paths. enum SleepGlyph { case off case on @@ -139,6 +159,10 @@ private final class CardView: NSView { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + private let power = PowerController() + private let agentMonitor = AgentMonitor() + private let connectivityMonitor = ConnectivityMonitor() + private let lidMonitor = LidMonitor() private var statusItem: NSStatusItem! private var timer: Timer? private let onGlyph = makeCupGlyph(.on) @@ -149,17 +173,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let popover = NSPopover() private var toggleSwitch: NSSwitch! private var mainCard: CardView! // group-1 card; gets the brand-violet wash when awake - private var headerMark: NSImageView! // header coffee mark; tints violet when awake + private var headerMark: NSImageView! // header mark; tints violet when awake private var captionLabel: NSTextField! private var floorValueLabel: NSTextField! private var floorSlider: NSSlider! private var autoOffControl: NSSegmentedControl! private var countdownLabel: NSTextField! + private var internetSwitch: NSSwitch! + private var lowPowerSwitch: NSSwitch! + private var agentAutoOffSwitch: NSSwitch! + private var agentSummaryLabel: NSTextField! + private var agentEmptyLabel: NSTextField! + private var agentRows: [AgentID: (name: NSTextField, status: NSTextField, setup: NSButton)] = [:] private var loginSwitch: NSSwitch! private var clickMonitor: Any? private var batteryFloorPercent = floorDefault + private var internetAutoOffEnabled = false + private var agentAutoOffEnabled = false + private var lowPowerAutoOffEnabled = true // ON by default: matches the long-standing safety behavior private var isOn = false private var userForcedOn = false // user deliberately turned it on; honor over the Low Power Mode auto-off (the hard battery floor still wins) + private var lastAgentSnapshots: [AgentToolSnapshot] = [] + private var lastInternetReachable = true + private var noAgentsSince: Date? + private var noInternetSince: Date? + private var agentStatusTicker: Timer? + private var agentRefreshInFlight = false + private var agentRefreshPending = false + private var pendingAgentRefreshCompletions: [() -> Void] = [] // Auto-off timer (in-memory; dies on quit, never survives a reboot) private var autoOffMinutes = 0 // 0 = none (stay on until off), 60, or 120 @@ -167,12 +208,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var countdownTicker: Timer? // 1 Hz label refresh, only while the popover is open private var timerEndDate: Date? - private let popoverWidth: CGFloat = 320 - private let popoverHeight: CGFloat = 432 + private let popoverWidth: CGFloat = 360 + private let popoverHeight: CGFloat = 644 func applicationDidFinishLaunching(_ notification: Notification) { NSApp.setActivationPolicy(.accessory) batteryFloorPercent = min(max((UserDefaults.standard.object(forKey: floorKey) as? Int) ?? floorDefault, floorMin), floorMax) + internetAutoOffEnabled = UserDefaults.standard.bool(forKey: internetAutoOffKey) + agentAutoOffEnabled = UserDefaults.standard.bool(forKey: agentAutoOffKey) + // Unset => true, so existing installs keep the Low Power Mode safety net they already had. + lowPowerAutoOffEnabled = (UserDefaults.standard.object(forKey: lpmAutoOffKey) as? Bool) ?? true statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = statusItem.button { button.image = offGlyph @@ -184,11 +229,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate { popover.contentSize = NSSize(width: popoverWidth, height: popoverHeight) popover.contentViewController = makeContentController() + // Lid-close -> turn displays off (system stays awake). disablesleep blocks only + // SYSTEM sleep, never display sleep, so explicitly idle the display side too. + lidMonitor.onLidClosed = { [weak self] in self?.handleLidClosed() } + lidMonitor.start() + refresh() // reflect TRUE system state on launch (never a stale assumption) + reconcileLidClosedDisplaySleep() timer = Timer.scheduledTimer(timeInterval: pollInterval, target: self, selector: #selector(poll), userInfo: nil, repeats: true) } + // Force all displays to sleep the instant the lid closes, but only while we're + // actively keeping the Mac awake. Needs no privilege: `pmset displaysleepnow` is an + // action, not a setting. Retrying shortly after the clamshell transition covers the + // momentary display/user-activity churn macOS may emit as the lid state settles. + private func handleLidClosed() { + guard isOn else { return } + power.sleepDisplayNow() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in + self?.reconcileLidClosedDisplaySleep() + } + } + + private func reconcileLidClosedDisplaySleep() { + guard isOn, LidMonitor.readClamshellClosed() else { return } + power.sleepDisplayNow() + } + // MARK: - Popover content (native NSSwitch toggle, macOS-aligned) private func makeContentController() -> NSViewController { let W = popoverWidth, pad: CGFloat = 16 @@ -204,15 +272,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { root.blendingMode = .behindWindow root.state = .followsWindowActiveState - // Header: small coffee mark + "Sleepless" (quiet system glyph, not a branded logo). - // The mark tints to the brand violet while the Mac is kept awake. + // Header: small coffee mark + app name. The mark tints to the brand violet while + // the Mac is kept awake. let mark = NSImageView(frame: NSRect(x: pad, y: 14, width: 18, height: 18)) let headerCup = makeCupGlyph(.on); headerCup.isTemplate = true mark.image = headerCup mark.contentTintColor = .labelColor root.addSubview(mark) headerMark = mark - let title = makeLabel("Sleepless", font: .systemFont(ofSize: 14, weight: .semibold), color: .labelColor) + let title = makeLabel(appDisplayName, font: .systemFont(ofSize: 14, weight: .semibold), color: .labelColor) title.frame = NSRect(x: pad + 24, y: 14, width: contentW - 24, height: 20) root.addSubview(title) @@ -228,7 +296,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let swH = swProto.height > 0 ? swProto.height : 21 // GROUP 1 — main switch + state caption - let g1y: CGFloat = 46, g1h: CGFloat = 84 + let g1y: CGFloat = 46, g1h: CGFloat = 78 let g1 = makeCard(NSRect(x: pad, y: g1y, width: contentW, height: g1h)) mainCard = g1 let rowLabel = makeLabel("Keep awake with lid closed", font: .systemFont(ofSize: 13), color: .labelColor) @@ -240,7 +308,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { toggleSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH) g1.addSubview(toggleSwitch) captionLabel = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor) - captionLabel.frame = NSRect(x: ci, y: ci + 30, width: cw, height: 32) + captionLabel.frame = NSRect(x: ci, y: ci + 28, width: cw, height: 30) captionLabel.usesSingleLineMode = false captionLabel.lineBreakMode = .byWordWrapping captionLabel.maximumNumberOfLines = 2 @@ -248,10 +316,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { g1.addSubview(captionLabel) // GROUP 2 — auto-off timer (label + segmented [Off | 1h | 2h] + countdown) - let g2y = g1y + g1h + 12, g2h: CGFloat = 78 + let g2y = g1y + g1h + 10, g2h: CGFloat = 58 let g2 = makeCard(NSRect(x: pad, y: g2y, width: contentW, height: g2h)) let timerLabel = makeLabel("Auto-off timer", font: .systemFont(ofSize: 13), color: .labelColor) - timerLabel.frame = NSRect(x: ci, y: ci + 3, width: 110, height: 22) + timerLabel.frame = NSRect(x: ci, y: ci, width: 110, height: 22) g2.addSubview(timerLabel) autoOffControl = NSSegmentedControl(labels: ["Off", "1h", "2h"], trackingMode: .selectOne, @@ -262,56 +330,117 @@ final class AppDelegate: NSObject, NSApplicationDelegate { autoOffControl.sizeToFit() let segSize = autoOffControl.frame.size let segW = segSize.width > 0 ? segSize.width : 150 - autoOffControl.frame = NSRect(x: contentW - ci - segW, y: ci, width: segW, height: max(segSize.height, 24)) + autoOffControl.frame = NSRect(x: contentW - ci - segW, y: ci - 1, width: segW, height: max(segSize.height, 24)) g2.addSubview(autoOffControl) countdownLabel = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor) - countdownLabel.frame = NSRect(x: ci, y: ci + 36, width: cw, height: 16) + countdownLabel.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 16) g2.addSubview(countdownLabel) - // GROUP 3 — battery-floor (label + value + slider + min/max hints) - let g3y = g2y + g2h + 12, g3h: CGFloat = 92 + // GROUP 3 — agents (only installed/detectable tools are shown) + let g3y = g2y + g2h + 10, g3h: CGFloat = 140 let g3 = makeCard(NSRect(x: pad, y: g3y, width: contentW, height: g3h)) + let agentsLabel = makeLabel("Auto-off when agents are idle", font: .systemFont(ofSize: 13), color: .labelColor) + agentsLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22) + g3.addSubview(agentsLabel) + agentAutoOffSwitch = NSSwitch() + agentAutoOffSwitch.target = self + agentAutoOffSwitch.action = #selector(agentAutoOffToggled(_:)) + agentAutoOffSwitch.state = agentAutoOffEnabled ? .on : .off + agentAutoOffSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH) + g3.addSubview(agentAutoOffSwitch) + agentSummaryLabel = makeLabel("Auto-off when no agents are running", font: .systemFont(ofSize: 12), color: .secondaryLabelColor) + agentSummaryLabel.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 17) + g3.addSubview(agentSummaryLabel) + agentEmptyLabel = makeLabel("No supported agent tools found", font: .systemFont(ofSize: 12), color: .tertiaryLabelColor) + agentEmptyLabel.frame = NSRect(x: ci, y: ci + 54, width: cw, height: 17) + g3.addSubview(agentEmptyLabel) + for (idx, id) in AgentID.allCases.enumerated() { + let y = ci + 52 + CGFloat(idx * 23) + let name = makeLabel(id.displayName, font: .systemFont(ofSize: 12), color: .labelColor) + name.frame = NSRect(x: ci, y: y, width: 112, height: 18) + let status = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor) + status.alignment = .right + status.frame = NSRect(x: ci + 112, y: y, width: cw - 112 - 66, height: 18) + let setup = NSButton(title: "Set Up", target: self, action: #selector(setupAgentIntegration(_:))) + setup.tag = idx + setup.controlSize = .small + setup.bezelStyle = .rounded + setup.frame = NSRect(x: contentW - ci - 58, y: y - 2, width: 58, height: 22) + g3.addSubview(name); g3.addSubview(status); g3.addSubview(setup) + agentRows[id] = (name, status, setup) + } + + // GROUP 4 — internet auto-off + let g4y = g3y + g3h + 10, g4h: CGFloat = 46 + let g4 = makeCard(NSRect(x: pad, y: g4y, width: contentW, height: g4h)) + let internetLabel = makeLabel("Auto-off at no internet", font: .systemFont(ofSize: 13), color: .labelColor) + internetLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22) + g4.addSubview(internetLabel) + internetSwitch = NSSwitch() + internetSwitch.target = self + internetSwitch.action = #selector(internetAutoOffToggled(_:)) + internetSwitch.state = internetAutoOffEnabled ? .on : .off + internetSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH) + g4.addSubview(internetSwitch) + + // GROUP 4b — Low Power Mode auto-off (surfaces the battery-side safety net as a control, + // grouped next to the battery floor since both protect a discharging battery) + let glpy = g4y + g4h + 10, glph: CGFloat = 46 + let glp = makeCard(NSRect(x: pad, y: glpy, width: contentW, height: glph)) + let lpmLabel = makeLabel("Auto-off in Low Power Mode", font: .systemFont(ofSize: 13), color: .labelColor) + lpmLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22) + glp.addSubview(lpmLabel) + lowPowerSwitch = NSSwitch() + lowPowerSwitch.target = self + lowPowerSwitch.action = #selector(lowPowerAutoOffToggled(_:)) + lowPowerSwitch.state = lowPowerAutoOffEnabled ? .on : .off + lowPowerSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH) + glp.addSubview(lowPowerSwitch) + + // GROUP 5 — battery-floor (label + value + slider + min/max hints) + let g5y = glpy + glph + 10, g5h: CGFloat = 80 + let g5 = makeCard(NSRect(x: pad, y: g5y, width: contentW, height: g5h)) let floorLabel = makeLabel("Auto-off at low battery", font: .systemFont(ofSize: 13), color: .labelColor) floorLabel.frame = NSRect(x: ci, y: ci, width: cw - 54, height: 18) - g3.addSubview(floorLabel) + g5.addSubview(floorLabel) floorValueLabel = makeLabel("\(batteryFloorPercent)%", font: .systemFont(ofSize: 13, weight: .semibold), color: .secondaryLabelColor) floorValueLabel.alignment = .right floorValueLabel.frame = NSRect(x: contentW - ci - 54, y: ci, width: 54, height: 18) - g3.addSubview(floorValueLabel) + g5.addSubview(floorValueLabel) floorSlider = NSSlider(value: Double(batteryFloorPercent), minValue: Double(floorMin), maxValue: Double(floorMax), target: self, action: #selector(floorSliderChanged(_:))) - floorSlider.isContinuous = true // live update while dragging + floorSlider.isContinuous = true floorSlider.controlSize = .regular - floorSlider.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 20) - g3.addSubview(floorSlider) + floorSlider.frame = NSRect(x: ci, y: ci + 22, width: cw, height: 20) + g5.addSubview(floorSlider) let minHint = makeLabel("\(floorMin)%", font: .systemFont(ofSize: 10), color: .tertiaryLabelColor) - minHint.frame = NSRect(x: ci, y: ci + 50, width: 34, height: 13) - g3.addSubview(minHint) + minHint.frame = NSRect(x: ci, y: ci + 44, width: 34, height: 13) + g5.addSubview(minHint) let maxHint = makeLabel("\(floorMax)%", font: .systemFont(ofSize: 10), color: .tertiaryLabelColor) maxHint.alignment = .right - maxHint.frame = NSRect(x: contentW - ci - 34, y: ci + 50, width: 34, height: 13) - g3.addSubview(maxHint) + maxHint.frame = NSRect(x: contentW - ci - 34, y: ci + 44, width: 34, height: 13) + g5.addSubview(maxHint) - // GROUP 4 — launch at login (off by default; never auto-enables sleep prevention) - let g4y = g3y + g3h + 12, g4h: CGFloat = 46 - let g4 = makeCard(NSRect(x: pad, y: g4y, width: contentW, height: g4h)) + // GROUP 6 — launch at login (off by default; never auto-enables sleep prevention) + let g6y = g5y + g5h + 10, g6h: CGFloat = 42 + let g6 = makeCard(NSRect(x: pad, y: g6y, width: contentW, height: g6h)) let loginLabel = makeLabel("Launch at login", font: .systemFont(ofSize: 13), color: .labelColor) - loginLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22) - g4.addSubview(loginLabel) + loginLabel.frame = NSRect(x: ci, y: 10, width: cw - swW - 8, height: 22) + g6.addSubview(loginLabel) loginSwitch = NSSwitch() loginSwitch.target = self loginSwitch.action = #selector(loginToggled(_:)) loginSwitch.state = loginItemEnabled() ? .on : .off - loginSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH) - g4.addSubview(loginSwitch) + loginSwitch.frame = NSRect(x: contentW - ci - swW, y: 10 + (22 - swH) / 2, width: swW, height: swH) + g6.addSubview(loginSwitch) // Footer — Quit (separated by space, not a hairline) - let quit = NSButton(title: "Quit Sleepless", target: self, action: #selector(quit)) + let quit = NSButton(title: "Quit \(appDisplayName)", target: self, action: #selector(quit)) quit.controlSize = .regular quit.bezelStyle = .rounded quit.sizeToFit() let qs = quit.frame.size - quit.frame = NSRect(x: W - pad - qs.width, y: g4y + g4h + 12, width: qs.width, height: qs.height) + quit.frame = NSRect(x: W - pad - qs.width, y: g6y + g6h + 10, width: qs.width, height: qs.height) root.addSubview(quit) let vc = NSViewController() @@ -329,19 +458,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return t } - // MARK: - Click the menu-bar cup to open/close the popover + // MARK: - Click the menu-bar glyph to open/close the popover @objc private func statusClicked() { if popover.isShown { closePopover() } else { openPopover() } } private func openPopover() { refresh() // sync switch/caption to TRUE state before showing + refreshAgentStatus() loginSwitch?.state = loginItemEnabled() ? .on : .off guard let button = statusItem.button else { return } NSApp.activate(ignoringOtherApps: true) popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) popover.contentViewController?.view.window?.makeKey() if keepAwakeTimer != nil { startCountdownTicker() } + startAgentStatusTicker() updateCountdownLabel() // Close when the user clicks anywhere outside the app (status bar, another app, desktop). clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in @@ -352,6 +483,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func closePopover() { popover.performClose(nil) countdownTicker?.invalidate(); countdownTicker = nil // stop the 1 Hz label refresh (keep-awake timer keeps running) + agentStatusTicker?.invalidate(); agentStatusTicker = nil if let monitor = clickMonitor { NSEvent.removeMonitor(monitor); clickMonitor = nil } } @@ -383,50 +515,79 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // A deliberate, successful turn-on wins over the Low Power Mode auto-off (hard floor still wins). userForcedOn = wantOn && result == .ok refresh() // applies UI + safety nets; switch reflects reality + reconcileLidClosedDisplaySleep() if isOn, autoOffMinutes > 0 { startKeepAwakeTimer(minutes: autoOffMinutes) } return false } // Install the one-time scoped grant via a SINGLE native macOS authorization (the - // standard Touch ID / password sheet) — no Terminal. Runs the bundled, audited - // grant.sh as root through osascript's "with administrator privileges"; grant.sh is - // root-aware so it writes the sudoers drop-in directly with no inner sudo prompt. + // standard Touch ID / password sheet) — no Terminal. The privileged script is + // generated from constants baked into this binary, not loaded from the mutable app + // bundle, then validated with visudo before installation. // Returns true once the passwordless grant is in place; after that the app never asks again. @discardableResult private func installGrantViaAuth() -> Bool { let intro = NSAlert() intro.alertStyle = .informational intro.messageText = "Enable keeping your Mac awake" - intro.informativeText = "Sleepless flips a protected macOS setting (pmset disablesleep), so it needs your permission once. macOS will ask you to authenticate (Touch ID or your password). After that the switch works instantly, with no more prompts." + intro.informativeText = "\(appDisplayName) flips a protected macOS setting (pmset disablesleep), so it needs your permission once. macOS will ask you to authenticate (Touch ID or your password). After that the switch works instantly, with no more prompts." intro.addButton(withTitle: "Enable") intro.addButton(withTitle: "Not now") NSApp.activate(ignoringOtherApps: true) guard intro.runModal() == .alertFirstButtonReturn else { return false } - guard let res = Bundle.main.resourcePath else { return false } - let grant = res + "/grant.sh" - // Pass the REAL user: under the native auth sheet grant.sh runs as root with - // SUDO_USER unset, so without this the grant would be written for "root" (useless). - let shellCmd = "SLEEPLESS_USER='\(NSUserName())' /bin/bash '\(grant)' --yes" - // escape for an AppleScript string literal, then run with one native auth sheet - let escaped = shellCmd.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") - let osa = "do shell script \"\(escaped)\" with administrator privileges" + guard let userSpec = sudoersUserSpec() else { + notify("Couldn't set up permission: unsupported user ID.") + return false + } + + let grant = "\(userSpec) \(sudoersCommandGrant)" + let installScript = [ + "set -euo pipefail", + "tmp=\"$(/usr/bin/mktemp)\"", + "trap '/bin/rm -f \"$tmp\"' EXIT", + "/usr/bin/printf '%s\\n' \(shellSingleQuoted(grant)) > \"$tmp\"", + "/usr/sbin/visudo -cf \"$tmp\" >/dev/null", + "/usr/bin/install -m 0440 -o root -g wheel \"$tmp\" \(shellSingleQuoted(sudoersDropInPath))", + "/usr/sbin/visudo -c >/dev/null" + ].joined(separator: "; ") + let shellCmd = "/bin/bash -c \(shellSingleQuoted(installScript))" + let osa = "do shell script \(appleScriptStringLiteral(shellCmd)) with administrator privileges" let proc = Process() proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") proc.arguments = ["-e", osa] - proc.standardOutput = Pipe(); proc.standardError = Pipe() - do { try proc.run(); proc.waitUntilExit() } + proc.standardOutput = FileHandle.nullDevice + let errPipe = Pipe() + proc.standardError = errPipe + let errData: Data + do { + try proc.run() + errData = errPipe.fileHandleForReading.readDataToEndOfFile() + proc.waitUntilExit() + } catch { notify("Couldn't start the one-time setup."); return false } - if proc.terminationStatus == 0 { return true } // grant.sh installed the rule successfully + if proc.terminationStatus == 0 { return true } // sudoers drop-in installed successfully if proc.terminationStatus != 128 { // 128 = user cancelled the auth sheet + let err = String(data: errData, encoding: .utf8) ?? "" + if !err.isEmpty { NSLog("Sleepless setup failed: %@", err) } notify("Setup didn't complete. Try again, or run grant.sh from the app bundle.") } return false } - // A brief, subtle pulse on the menu-bar glyph whenever the state (and thus the cup - // shape) changes, so the change is noticeable. Opacity-only: no layer geometry is - // mutated, so it can't shift the status item on any macOS version. + private func sudoersUserSpec() -> String? { + let uid = getuid() + guard uid > 0 else { return nil } + return "#\(uid)" + } + + private func shellSingleQuoted(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + // A brief, subtle pulse on the menu-bar glyph whenever the state changes, so the + // change is noticeable. Opacity-only: no layer geometry is mutated, so it can't shift + // the status item on any macOS version. private func pulseStatusItem() { guard let b = statusItem.button else { return } b.wantsLayer = true @@ -438,7 +599,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { b.layer?.add(pulse, forKey: "statePulse") } - @objc private func poll() { refresh() } + @objc private func poll() { + refreshAgentStatus() + connectivityMonitor.checkNow { [weak self] reachable in + guard let self else { return } + self.lastInternetReachable = reachable + self.renderInternetSection() + self.refresh() + } + } // MARK: - Auto-off timer (Feature 1) @objc private func autoOffChanged(_ sender: NSSegmentedControl) { @@ -478,7 +647,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { autoOffMinutes = 0 autoOffControl?.selectedSegment = 0 applyUI(on: readSleepDisabled()) - notify("Auto-off timer ended. Sleepless turned off.") + notify("Auto-off timer ended. \(appDisplayName) turned off.") } private func startCountdownTicker() { @@ -498,6 +667,151 @@ final class AppDelegate: NSObject, NSApplicationDelegate { countdownLabel?.stringValue = "Auto-off in \(t)" } + // MARK: - Agent + internet cutoffs + @objc private func agentAutoOffToggled(_ sender: NSSwitch) { + if sender.state == .on { + refreshAgentStatus { [weak self] in + guard let self else { return } + let healthyCount = self.lastAgentSnapshots.filter { $0.status != .setupNeeded }.count + if self.lastAgentSnapshots.isEmpty { + self.agentAutoOffEnabled = false + sender.state = .off + UserDefaults.standard.set(false, forKey: agentAutoOffKey) + self.notify("No supported agent tools found.") + } else if healthyCount == 0 { + self.agentAutoOffEnabled = false + sender.state = .off + UserDefaults.standard.set(false, forKey: agentAutoOffKey) + self.notify("Set up an agent detector before enabling agent auto-off.") + } else { + self.agentAutoOffEnabled = true + UserDefaults.standard.set(true, forKey: agentAutoOffKey) + } + self.renderAgentSection() + } + return + } + agentAutoOffEnabled = false + UserDefaults.standard.set(false, forKey: agentAutoOffKey) + noAgentsSince = nil + renderAgentSection() + } + + @objc private func internetAutoOffToggled(_ sender: NSSwitch) { + internetAutoOffEnabled = sender.state == .on + UserDefaults.standard.set(internetAutoOffEnabled, forKey: internetAutoOffKey) + if !internetAutoOffEnabled { noInternetSince = nil } + renderInternetSection() + } + + @objc private func lowPowerAutoOffToggled(_ sender: NSSwitch) { + lowPowerAutoOffEnabled = sender.state == .on + UserDefaults.standard.set(lowPowerAutoOffEnabled, forKey: lpmAutoOffKey) + // Re-evaluate now so enabling it while already in Low Power Mode can take effect this + // tick, and the caption's cutoff list updates immediately. A deliberate turn-on this + // session (userForcedOn) still wins; the hard battery floor still always wins. + refresh() + } + + @objc private func setupAgentIntegration(_ sender: NSButton) { + guard sender.tag >= 0, sender.tag < AgentID.allCases.count else { return } + let id = AgentID.allCases[sender.tag] + let result = agentMonitor.installIntegration(for: id) + if result.ok { + notify(result.message) + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = "\(id.displayName) detector set up" + alert.informativeText = "\(appDisplayName) installed an app-wide hook for \(id.displayName). The row should now show Idle, and it will show Active only while the hook is producing fresh activity heartbeats." + alert.addButton(withTitle: "OK") + alert.runModal() + } else { + notify("Couldn't set up \(id.displayName). Details were logged.") + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Couldn't set up \(id.displayName)" + alert.informativeText = "\(result.message)\n\nDebug log:\n\(result.logURL.path)" + alert.addButton(withTitle: "OK") + alert.runModal() + } + refreshAgentStatus() + } + + private func startAgentStatusTicker() { + agentStatusTicker?.invalidate() + agentStatusTicker = Timer.scheduledTimer(timeInterval: visibleAgentRefreshInterval, target: self, + selector: #selector(agentStatusTick), userInfo: nil, repeats: true) + } + + @objc private func agentStatusTick() { refreshAgentStatus() } + + private func refreshAgentStatus(completion: (() -> Void)? = nil) { + guard !agentRefreshInFlight else { + agentRefreshPending = true + if let completion { pendingAgentRefreshCompletions.append(completion) } + return + } + agentRefreshInFlight = true + agentMonitor.snapshotsAsync { [weak self] snapshots in + guard let self else { return } + self.agentRefreshInFlight = false + self.lastAgentSnapshots = snapshots + self.renderAgentSection() + completion?() + if self.agentRefreshPending { + let completions = self.pendingAgentRefreshCompletions + self.pendingAgentRefreshCompletions = [] + self.agentRefreshPending = false + self.refreshAgentStatus { + completions.forEach { $0() } + } + } + } + } + + private func renderAgentSection() { + agentAutoOffSwitch?.state = agentAutoOffEnabled ? .on : .off + let activeCount = lastAgentSnapshots.filter { $0.status == .active }.count + let healthyCount = lastAgentSnapshots.filter { $0.status != .setupNeeded }.count + if lastAgentSnapshots.isEmpty { + agentSummaryLabel?.stringValue = "Auto-off when no agents are running" + agentEmptyLabel?.isHidden = false + agentAutoOffSwitch?.isEnabled = false + } else { + agentEmptyLabel?.isHidden = true + agentAutoOffSwitch?.isEnabled = true + if activeCount > 0 { + agentSummaryLabel?.stringValue = "\(activeCount) active agent\(activeCount == 1 ? "" : "s") detected" + } else if healthyCount == 0 { + agentSummaryLabel?.stringValue = "Set up a detector before auto-off can act" + } else { + agentSummaryLabel?.stringValue = "No active agents detected" + } + } + + for id in AgentID.allCases { + guard let row = agentRows[id] else { continue } + guard let snapshot = lastAgentSnapshots.first(where: { $0.id == id }) else { + row.name.isHidden = true + row.status.isHidden = true + row.setup.isHidden = true + continue + } + row.name.isHidden = false + row.status.isHidden = false + let setupNeeded = snapshot.status == .setupNeeded + row.setup.isHidden = !setupNeeded + let statusRightEdge = setupNeeded ? row.setup.frame.minX - 8 : row.setup.frame.maxX + row.status.frame.size.width = max(0, statusRightEdge - row.status.frame.minX) + row.status.stringValue = snapshot.status.rawValue + row.status.textColor = snapshot.status == .active ? brandAccentSoft : .secondaryLabelColor + } + } + + private func renderInternetSection() { + internetSwitch?.state = internetAutoOffEnabled ? .on : .off + } + // MARK: - Launch at login (Feature 2) — OFF by default; never re-enables sleep prevention @objc private func loginToggled(_ sender: NSSwitch) { do { @@ -517,13 +831,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let on = readSleepDisabled() applyUI(on: on) if on { enforceSafetyNets() } + reconcileLidClosedDisplaySleep() } private func applyUI(on: Bool) { isOn = on if !on { cancelKeepAwakeTimer() } // going OFF clears any countdown/timer // ARMED = kept awake while actively discharging on battery, so the - // auto-off safety net is live. Distinct menu-bar glyph (cup + dot). + // auto-off safety net is live. Distinct menu-bar glyph (awake cup + dot). var armed = false if on { let (onBattery, discharging, _) = batteryStatus() @@ -531,30 +846,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } if let button = statusItem.button { let newImage = on ? (armed ? armedGlyph : onGlyph) : offGlyph - if button.image !== newImage { // state (cup shape) changed -> swap + pulse + if button.image !== newImage { // state changed -> swap + pulse button.image = newImage pulseStatusItem() } button.toolTip = on ? (armed - ? "Sleepless: on (battery). Auto-off at \(batteryFloorPercent)% or in Low Power Mode." - : "Sleepless: on. Stays awake with the lid closed.") - : "Sleepless: off. Sleeps normally." + ? "\(appDisplayName): on (battery). " + (lowPowerAutoOffEnabled + ? "Auto-off at \(batteryFloorPercent)% or in Low Power Mode." + : "Auto-off at \(batteryFloorPercent)%.") + : "\(appDisplayName): on. Stays awake with the lid closed; the display turns off to save power.") + : "\(appDisplayName): off. Sleeps normally." } toggleSwitch?.state = on ? .on : .off // Brand-violet accent communicates the privileged "awake" state at a glance. mainCard?.active = on headerMark?.contentTintColor = on ? brandAccentSoft : .labelColor renderText() + renderAgentSection() + renderInternetSection() updateCountdownLabel() } // Update text labels only (no pmset subprocess; safe to call on every slider tick). private func renderText() { floorValueLabel?.stringValue = "\(batteryFloorPercent)%" - captionLabel?.stringValue = isOn - ? "Stays awake when the lid is closed. Turns off at \(batteryFloorPercent)% battery or in Low Power Mode." - : "Sleeps normally when you close the lid." + if isOn { + // Each cutoff is a self-contained phrase so the sentence reads naturally no matter + // which optional safety nets are on (the hard battery floor is always present). + var cutoffs = ["below \(batteryFloorPercent)% battery"] + if lowPowerAutoOffEnabled { cutoffs.append("in Low Power Mode") } + if internetAutoOffEnabled { cutoffs.append("with no internet") } + if agentAutoOffEnabled { cutoffs.append("with no agents running") } + captionLabel?.stringValue = "Awake with the lid closed; the display turns off to save power. Turns off " + cutoffs.joined(separator: ", ") + "." + } else { + captionLabel?.stringValue = "Sleeps normally when you close the lid." + } } @objc private func floorSliderChanged(_ sender: NSSlider) { @@ -566,125 +893,100 @@ final class AppDelegate: NSObject, NSApplicationDelegate { renderText() } - // Result of the privileged keep-awake toggle, based on sudo's REAL exit status — not on a - // second, independent state read. `.ok` = the command ran; `.grantMissing` = the passwordless - // sudoers grant isn't installed (sudo -n refused), the one case that warrants setup; `.failed` - // = any other error. Using sudo's own result (instead of re-reading SleepDisabled) is the fix: - // a safety net flipping sleep back on must never look like "permission missing" and re-prompt. - private enum ToggleResult: Equatable { case ok, grantMissing, failed(String) } - @discardableResult private func setDisableSleep(_ on: Bool) -> ToggleResult { - // sudo -n: never prompt (GUI app has no TTY). The exact argument vector matches the - // NOPASSWD sudoers grant, so this runs without a password. - let (exit, _, err) = runPrivileged(["-n", "/usr/bin/pmset", "-a", "disablesleep", on ? "1" : "0"]) - let result: ToggleResult - if exit == 0 { - result = .ok - } else if err.range(of: "a password is required", options: .caseInsensitive) != nil - || err.range(of: "not allowed", options: .caseInsensitive) != nil - || err.range(of: "may not run", options: .caseInsensitive) != nil { - result = .grantMissing // grant absent/removed -> sudo -n refused to run passwordless - } else { - result = .failed(err.isEmpty ? "exit \(exit)" : err.trimmingCharacters(in: .whitespacesAndNewlines)) - } - return result - } - - // Run a privileged command via sudo, capturing exit status + stderr (which the generic - // runCapture discards). stdin is /dev/null so a GUI process with no controlling TTY can - // never block on a prompt. This is what lets the app KNOW whether its own toggle worked. - private func runPrivileged(_ args: [String]) -> (exit: Int32, out: String, err: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") - process.arguments = args - var env = ProcessInfo.processInfo.environment - env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin" - env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path - process.environment = env - let outPipe = Pipe(), errPipe = Pipe() - process.standardOutput = outPipe - process.standardError = errPipe - process.standardInput = FileHandle.nullDevice - do { try process.run() } - catch { - NSLog("Sleepless: failed to launch sudo: %@", error.localizedDescription) - return (-1, "", "launch failed: \(error.localizedDescription)") - } - let outData = outPipe.fileHandleForReading.readDataToEndOfFile() - let errData = errPipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return (process.terminationStatus, - String(data: outData, encoding: .utf8) ?? "", - String(data: errData, encoding: .utf8) ?? "") + power.setDisableSleep(on) } // MARK: - Battery + Low-Power-Mode safety nets (silent; no extra UI) — Feature 3 private func enforceSafetyNets() { let (onBattery, discharging, percent) = batteryStatus() - guard onBattery, discharging else { return } - // Hard battery floor ALWAYS wins, even over a deliberate turn-on: never drain to empty. - if percent <= batteryFloorPercent { - setDisableSleep(false); userForcedOn = false - applyUI(on: readSleepDisabled()) - notify("Battery low (\(percent)%). Sleepless turned off.") + if onBattery, discharging { + // Hard battery floor ALWAYS wins, even over a deliberate turn-on: never drain to empty. + if percent <= batteryFloorPercent { + turnOffFromSafetyNet("Battery low (\(percent)%). \(appDisplayName) turned off.") + userForcedOn = false + return + } + // Low Power Mode auto-off (user-controllable; ON by default), UNLESS the user + // deliberately chose to keep awake this session. Toggle off => LPM is ignored entirely. + if lowPowerAutoOffEnabled && ProcessInfo.processInfo.isLowPowerModeEnabled && !userForcedOn { + turnOffFromSafetyNet("Low Power Mode on. \(appDisplayName) turned off.") + return + } + } + + enforceInternetCutoff() + enforceAgentCutoff() + } + + private func enforceInternetCutoff() { + guard internetAutoOffEnabled else { noInternetSince = nil; return } + if lastInternetReachable { + noInternetSince = nil + return + } + let since = noInternetSince ?? Date() + noInternetSince = since + if Date().timeIntervalSince(since) >= cutoffGraceInterval { + noInternetSince = nil + turnOffFromSafetyNet("No internet connection. \(appDisplayName) turned off.") + } + } + + private func enforceAgentCutoff() { + guard agentAutoOffEnabled else { noAgentsSince = nil; return } + if lastAgentSnapshots.isEmpty { + agentAutoOffEnabled = false + UserDefaults.standard.set(false, forKey: agentAutoOffKey) + noAgentsSince = nil + renderAgentSection() + notify("No supported agent tools found. Agent auto-off was disabled.") return } - // Low Power Mode auto-off, UNLESS the user deliberately chose to keep awake this session. - if ProcessInfo.processInfo.isLowPowerModeEnabled && !userForcedOn { - setDisableSleep(false) - applyUI(on: readSleepDisabled()) - notify("Low Power Mode on. Sleepless turned off.") + let healthy = lastAgentSnapshots.filter { $0.status != .setupNeeded } + guard !healthy.isEmpty else { noAgentsSince = nil; return } + if healthy.contains(where: { $0.status == .active }) { + noAgentsSince = nil + return + } + let since = noAgentsSince ?? Date() + noAgentsSince = since + if Date().timeIntervalSince(since) >= cutoffGraceInterval { + noAgentsSince = nil + turnOffFromSafetyNet("No agents running. \(appDisplayName) turned off.") } } + private func turnOffFromSafetyNet(_ message: String) { + setDisableSleep(false) + applyUI(on: readSleepDisabled()) + notify(message) + } + // MARK: - Readers (no root needed) private func readSleepDisabled() -> Bool { - let out = runCapture("/usr/bin/pmset", ["-g"]) - for line in out.split(whereSeparator: { $0 == "\n" }) { - if line.range(of: "SleepDisabled", options: .caseInsensitive) != nil { - let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) - if let last = toks.last { return last == "1" } - } - } - return false // line absent -> OFF + power.readSleepDisabled() } private func batteryStatus() -> (onBattery: Bool, discharging: Bool, percent: Int) { - let out = runCapture("/usr/bin/pmset", ["-g", "batt"]) - let onBattery = out.contains("Battery Power") - let discharging = out.range(of: "discharging", options: .caseInsensitive) != nil - var percent = 100 - for tok in out.split(whereSeparator: { " \t\n;".contains($0) }) { - if tok.hasSuffix("%"), let v = Int(tok.dropLast()) { percent = v; break } - } - return (onBattery, discharging, percent) + let status = power.batteryStatus() + return (status.onBattery, status.discharging, status.percent) } // MARK: - Notification (mirrors Nexus' osascript approach) private func notify(_ message: String) { - let script = "display notification \"\(message)\" with title \"Sleepless\" sound name \"Tink\"" - _ = runCapture("/usr/bin/osascript", ["-e", script]) + let script = "display notification \(appleScriptStringLiteral(message)) with title \(appleScriptStringLiteral(appDisplayName)) sound name \(appleScriptStringLiteral("Tink"))" + _ = ShellRunner.capture("/usr/bin/osascript", ["-e", script]) } - // MARK: - Process runner (explicit PATH/HOME; captures stdout) - @discardableResult - private func runCapture(_ launchPath: String, _ args: [String]) -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: launchPath) - process.arguments = args - var env = ProcessInfo.processInfo.environment - env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin" - env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path - process.environment = env - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = Pipe() - do { try process.run() } - catch { NSLog("Sleepless: failed to launch %@: %@", launchPath, error.localizedDescription); return "" } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return String(data: data, encoding: .utf8) ?? "" + private func appleScriptStringLiteral(_ s: String) -> String { + let escaped = s + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\r", with: "\\r") + .replacingOccurrences(of: "\n", with: "\\n") + return "\"\(escaped)\"" } @objc private func quit() { NSApp.terminate(nil) } diff --git a/AppLogger.swift b/AppLogger.swift new file mode 100644 index 0000000..3138286 --- /dev/null +++ b/AppLogger.swift @@ -0,0 +1,88 @@ +import Foundation + +enum AppLogger { + private static let subsystem = "Sleepless" + private static let maxBytes = 256 * 1024 + private static let queue = DispatchQueue(label: "Sleepless.AppLogger", qos: .utility) + private static let fileManager = FileManager.default + + static var logURL: URL { + let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Caches", isDirectory: true) + return base + .appendingPathComponent("com.aboudjem.Sleepless", isDirectory: true) + .appendingPathComponent("setup-diagnostics.jsonl") + } + + static func info(_ event: String, _ fields: [String: String] = [:]) { + write(level: "INFO", event: event, fields: fields) + } + + static func error(_ event: String, _ fields: [String: String] = [:]) { + write(level: "ERROR", event: event, fields: fields) + } + + private static func write(level: String, event: String, fields: [String: String]) { + queue.async { + var object: [String: Any] = [ + "ts": ISO8601DateFormatter().string(from: Date()), + "level": level.lowercased(), + "event": event, + "pid": Int(ProcessInfo.processInfo.processIdentifier) + ] + if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String { + object["appVersion"] = version + } + if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String { + object["build"] = build + } + for (key, value) in fields { + object[key] = redact(value) + } + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else { + NSLog("%@: failed to encode log event %@", subsystem, event) + return + } + let encoded = String(decoding: data, as: UTF8.self) + let line = String(encoded.prefix(4096)) + "\n" + append(line) + } + } + + private static func append(_ line: String) { + let url = logURL + do { + try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + rotateIfNeeded(url) + let data = Data(line.utf8) + if fileManager.fileExists(atPath: url.path) { + let handle = try FileHandle(forWritingTo: url) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } else { + try data.write(to: url, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } + } catch { + NSLog("%@: failed to write log: %@", subsystem, error.localizedDescription) + } + } + + private static func rotateIfNeeded(_ url: URL) { + guard let attrs = try? fileManager.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? NSNumber, + size.intValue > maxBytes else { + return + } + let archive = url.deletingLastPathComponent().appendingPathComponent("setup-diagnostics.jsonl.1") + try? fileManager.removeItem(at: archive) + try? fileManager.moveItem(at: url, to: archive) + } + + private static func redact(_ value: String) -> String { + let home = fileManager.homeDirectoryForCurrentUser.path + return value.replacingOccurrences(of: home, with: "~") + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index af0109d..2ae4735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Display-off on lid close: while keeping the Mac awake, Sleepless now turns the screen + off the instant you close the lid, so the system keeps running in the background with no + display power draw. `disablesleep` only blocks _system_ sleep, never _display_ sleep, so + the two are controlled separately; Sleepless watches the clamshell sensor and issues + `pmset displaysleepnow` (an action that needs no root and no change to the scoped grant) + on lid close. +- Agent-aware auto-off: Sleepless can show local Claude Code, Codex, and Cursor agent + status and, when enabled, turn itself off after no monitored agents are active for a + grace period. +- No-internet auto-off: an opt-in cutoff that turns Sleepless off after sustained public + internet reachability loss. +- Low Power Mode auto-off is now an explicit switch in the popover (ON by default). The + battery-side safety net that was already running silently is now visible and can be + turned off if you want to stay awake on battery even in Low Power Mode. The hard battery + floor and a deliberate same-session turn-on still behave as before. +- Local agent-detection documentation and an ADR that rules out UI scraping, Screen + Recording, broad filesystem searches, and cloud-only monitoring. + +### Changed + +- Reworded the "keep awake" caption so each auto-off cutoff reads as a complete phrase + ("Turns off below 15% battery, in Low Power Mode, ...") instead of the previous + "Turns off at 15% battery, Low Power Mode" fragment. +- Split the native app into focused Swift files for power control, command execution, + agent monitoring, and connectivity monitoring. + ## [1.2.7] - 2026-06-03 ### Changed + - Redesigned the menu-bar icon so the three states are unmistakable at a glance. It stays a monochrome template icon (adapts to light/dark menu bars and inverts on highlight, the macOS convention), but now changes shape instead of just filling in: @@ -21,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.6] - 2026-06-03 ### Fixed + - The switch could still show a password prompt or look like it "wouldn't stay on" in edge cases, because the app judged success by re-reading the sleep state with a second `pmset` call right after toggling, rather than trusting whether the privileged command @@ -32,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 a permission problem. ### Changed + - The privileged toggle now captures its real exit status and stderr (previously discarded) and runs with its input detached from any terminal, so a GUI launch can never stall on a prompt and the app always knows whether the toggle worked. @@ -39,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.5] - 2026-06-02 ### Fixed + - In Low Power Mode the switch would not stay on and the password prompt kept reappearing. Cause: the Low Power Mode safety net turned it back off, and the app misread that off-state as a missing permission and re-prompted. The app now checks @@ -46,6 +78,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 so a safety-net turn-off never triggers a setup prompt. ### Changed + - A deliberate turn-on now overrides the Low Power Mode auto-off for that session, so the switch stays on when you explicitly ask for it. The hard battery floor (default 15%) still always cuts in to protect against draining the Mac flat. @@ -53,6 +86,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.4] - 2026-06-02 ### Fixed + - The switch kept asking for the password even after the permission was correctly installed. The app pre-checked the grant with `sudo -l`, but listing sudo privileges itself needs authentication even when a NOPASSWD rule is present, so the check always @@ -62,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.3] - 2026-06-02 ### Fixed + - The one-time setup installed the grant for the wrong user. Under the native auth sheet, grant.sh runs as root with `SUDO_USER` unset, so it wrote the rule for `root` instead of the real user, which meant the switch never engaged and kept re-asking for @@ -72,6 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.2] - 2026-06-02 ### Changed + - Setting up the one-time permission no longer means running anything in Terminal. The first time you flip the switch on, Sleepless installs the scoped grant itself through a single native macOS authentication sheet (Touch ID or your password). @@ -82,18 +118,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.2.1] - 2026-06-02 ### Fixed + - The keep-awake switch no longer snaps back with no explanation when the one-time passwordless grant is missing. If turning it on cannot engage `disablesleep`, Sleepless now shows a short alert that names the cause and offers to copy the `grant.sh` command or open Terminal, so the toggle is never a silent dead end. ### Added + - A brief pulse on the menu-bar cup whenever the state changes, so the empty-cup to full-cup transition is easy to notice. ## [1.2.0] - 2026-06-02 ### Changed + - New look. Sleepless now wears a vibrant 2026 "Liquid Glass" design in an indigo, violet, and fuchsia palette, across the app icon, the menu-bar popover, the landing page, and all brand art. The coffee-cup metaphor and the three menu-bar states stay @@ -105,11 +144,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 with the same white cup, plus a soft steam wisp at larger sizes. ### Added + - A richer badge row and a security and version trust strip (build-provenance attestation, SHA-256 checksums, no telemetry, MIT, CI, platform) on the landing page and across all six READMEs. ### Unchanged + - Same single AppKit file, no daemon, no kernel extension, no Dock icon. `disablesleep` still resets on reboot, the scoped `/etc/sudoers.d` grant is identical, and every verified fact, FAQ answer, and comparison result is unchanged. Only the visual layer @@ -118,6 +159,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.1.0] - 2026-06-02 ### Added + - Auto-off timer. Keep the Mac awake for 1 hour or 2 hours with a live countdown, then Sleepless turns itself back off. The timer is in-memory only, so quitting or rebooting clears it. @@ -127,6 +169,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 off, the same safety shape as the battery floor. ### Changed + - New coffee-cup icon. The menu-bar glyph and the app icon are now a coffee cup instead of a moon: an empty cup means normal sleep, a full cup means kept awake, and a full cup with a small dot means awake on battery with the auto-off net live. The old @@ -135,12 +178,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 launch-at-login toggle, with the state caption noting both auto-off conditions. ### Unchanged + - Still one AppKit file, no daemon, no kernel extension, no Dock icon. `disablesleep` still resets on reboot, and the tightly scoped `/etc/sudoers.d` grant is the same. ## [1.0.0] - 2026-06-01 ### Added + - Menu-bar toggle that keeps a Mac awake with the lid closed, on battery, with no external display, via the undocumented `pmset disablesleep` setting. - Passwordless toggling through a tightly scoped `/etc/sudoers.d` grant limited to the diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d92442d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,23 @@ +# Context + +## Glossary + +- **Keep-awake state**: The user-controlled state where Sleepless keeps the Mac awake when it would otherwise sleep. +- **System sleep vs display sleep**: Two independent macOS power domains. `pmset disablesleep` blocks only system sleep, so the Mac keeps running while the display is free to turn off. Sleepless keeps the system awake but lets (and on lid close, makes) the display sleep so a kept-awake Mac draws no display power. +- **Display-off on lid close**: While in the keep-awake state, Sleepless turns displays off the moment the lid closes (via `pmset displaysleepnow`, which needs no privilege), keeping the system active in the background with no screen power draw. +- **Agent auto-off**: A safety cutoff that may end the keep-awake state when no monitored agents are active. It does not start or re-enable the keep-awake state. +- **Active agent**: A monitored coding-agent session that Sleepless can detect through local CLI, process, or session signals without reading another app's UI. A live session counts as active even when it is waiting for input or approval. Detection may use at most one optional, non-screen macOS permission when it provides reliable non-UI signals. +- **Locally observable agent**: Agent work with a local process, worker, session signal, or integration heartbeat. Cloud-only agent work without a local signal is outside Sleepless' monitoring contract. +- **Monitored agent tool**: An installed coding-agent tool for which Sleepless has a reliable local detector. Tools without reliable detectors are not shown in agent status and do not affect agent auto-off. +- **Installed agent tool**: A coding-agent tool discovered through bounded, tool-specific signals such as a validated CLI executable, a known app bundle identifier, or an official local integration. Sleepless does not use exhaustive filesystem searches to discover tools. +- **Agent integration**: An app-wide, opt-in integration such as a hook or heartbeat that helps Sleepless detect active agents without UI scraping. Project-by-project integrations are too high-friction to be required, and app-wide integrations are required only when default local detection is not reliable enough. +- **Healthy agent detection**: The state where Sleepless has the required local signals and any required permission to evaluate at least one monitored agent tool. Agent auto-off starts inactive, asks for required permission only when the user enables it, and stays enabled while at least one monitored tool is available. +- **Agent status**: The user-visible state of a monitored agent tool, shown as Active, Idle, or Setup needed. +- **Agent setup**: A per-tool action in the controls popover that sets up required app-wide integrations or prompts for required permission. Detailed explanation lives in documentation rather than a setup wizard. +- **No agent tools available**: The state where Sleepless finds no monitored agent tools. The controls popover explains that no supported agent tools were found, and agent auto-off is unavailable. +- **Controls popover**: The single menu-bar popover where Sleepless exposes keep-awake controls, safety cutoffs, and monitored agent status. +- **Native lightweight app**: Sleepless remains a small native macOS menu-bar app, but implementation may be split across focused Swift files when features are too broad for a single source file. +- **No internet connection**: A sustained inability to reach the public internet across consecutive checks, determined from macOS network path status plus a lightweight HTTPS reachability probe. +- **Realtime agent status**: A visible status that updates every two seconds while the user is looking at the controls. +- **Auto-off grace period**: A two-minute delay before an agent or internet safety cutoff acts, used to avoid turning off during transient network drops, tool restarts, or session handoffs. +- **Safety cutoff**: Any enabled condition that may end the keep-awake state. Safety cutoffs combine independently; any one of them may turn Sleepless off. New cutoffs default off until the user enables them. diff --git a/ConnectivityMonitor.swift b/ConnectivityMonitor.swift new file mode 100644 index 0000000..c3cbb61 --- /dev/null +++ b/ConnectivityMonitor.swift @@ -0,0 +1,39 @@ +import Foundation +import Network + +final class ConnectivityMonitor { + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "Sleepless.ConnectivityMonitor") + private var pathIsSatisfied = true + private let probeURL = URL(string: "https://www.apple.com/library/test/success.html")! + + init() { + monitor.pathUpdateHandler = { [weak self] path in + self?.queue.async { + self?.pathIsSatisfied = path.status == .satisfied + } + } + monitor.start(queue: queue) + } + + func checkNow(completion: @escaping (Bool) -> Void) { + queue.async { + let pathOk = self.pathIsSatisfied + guard pathOk else { + DispatchQueue.main.async { completion(false) } + return + } + + var request = URLRequest(url: self.probeURL) + request.httpMethod = "HEAD" + request.timeoutInterval = 5 + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + URLSession.shared.dataTask(with: request) { _, response, error in + let code = (response as? HTTPURLResponse)?.statusCode + let ok = error == nil && code.map { 200..<400 ~= $0 } == true + DispatchQueue.main.async { completion(ok) } + }.resume() + } + } +} diff --git a/LidMonitor.swift b/LidMonitor.swift new file mode 100644 index 0000000..1077515 --- /dev/null +++ b/LidMonitor.swift @@ -0,0 +1,110 @@ +// LidMonitor.swift — observe the laptop lid (clamshell) open/close state. +// +// Sleepless keeps the *system* awake with the lid closed (pmset disablesleep), but +// system sleep and DISPLAY sleep are independent: disablesleep never touches the +// display. macOS normally blanks the internal panel when the lid is shut (the +// clamshell sensor drives WindowServer), so the screen draws ~no power. This monitor +// makes that behaviour explicit and guaranteed: it watches for the lid closing and +// lets the app immediately put the display(s) to sleep, closing the edge-case window +// where a stray display assertion could otherwise keep the backlight lit and drain +// the battery while the Mac runs headless in the bag. +// +// Mechanism: IOPMrootDomain delivers kIOPMMessageClamshellStateChange as a general +// interest notification. We subscribe directly to that service, read the authoritative +// "AppleClamshellState" property on each notification, and fire a callback on +// open<->closed transitions. +// +// No daemon, no persisted state, no extra privilege: the port is reclaimed when the +// process exits, matching the rest of the app's "reboot resets everything" model. +import Foundation +import IOKit +import IOKit.pwr_mgt + +// IOKit message IDs. kIOPMMessageClamshellStateChange is a C macro: +// iokit_family_msg(sub_iokit_powermanagement, 0x100). +private let kMsgClamshellStateChange: UInt32 = 0xE003_4100 + +@MainActor +final class LidMonitor { + /// Fired when the lid transitions to CLOSED. + var onLidClosed: (() -> Void)? + /// Fired when the lid transitions to OPEN. + var onLidOpened: (() -> Void)? + + private var rootDomain: io_service_t = 0 + private var notifierObject: io_object_t = 0 + private var notifyPort: IONotificationPortRef? + private var lastClosed = false + private var started = false + + func start() { + guard !started else { return } + guard let port = IONotificationPortCreate(kIOMainPortDefault) else { return } + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain")) + guard service != 0 else { + IONotificationPortDestroy(port) + return + } + + let refcon = Unmanaged.passUnretained(self).toOpaque() + var notifier: io_object_t = 0 + let result = "IOGeneralInterest".withCString { interest in + IOServiceAddInterestNotification(port, service, interest, { refcon, _, messageType, _ in + guard let refcon else { return } + let monitor = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + MainActor.assumeIsolated { + monitor.handle(messageType: messageType) + } + }, refcon, ¬ifier) + } + guard result == KERN_SUCCESS else { + IOObjectRelease(service) + IONotificationPortDestroy(port) + return + } + + lastClosed = Self.readClamshellClosed() + rootDomain = service + notifierObject = notifier + notifyPort = port + CFRunLoopAddSource(CFRunLoopGetMain(), + IONotificationPortGetRunLoopSource(port).takeUnretainedValue(), + .commonModes) + started = true + } + + func stop() { + guard started else { return } + if let port = notifyPort { + CFRunLoopRemoveSource(CFRunLoopGetMain(), + IONotificationPortGetRunLoopSource(port).takeUnretainedValue(), + .commonModes) + } + if notifierObject != 0 { IOObjectRelease(notifierObject) } + if rootDomain != 0 { IOObjectRelease(rootDomain) } + if let port = notifyPort { IONotificationPortDestroy(port) } + notifyPort = nil + notifierObject = 0 + rootDomain = 0 + started = false + } + + private func handle(messageType: UInt32) { + guard messageType == kMsgClamshellStateChange else { return } + let closed = Self.readClamshellClosed() + guard closed != lastClosed else { return } + lastClosed = closed + if closed { onLidClosed?() } else { onLidOpened?() } + } + + /// True when the lid is shut. Reads IOPMrootDomain's "AppleClamshellState" + /// (Yes = closed, No/absent = open). No root required. + static func readClamshellClosed() -> Bool { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain")) + guard service != 0 else { return false } + defer { IOObjectRelease(service) } + guard let cf = IORegistryEntryCreateCFProperty(service, "AppleClamshellState" as CFString, + kCFAllocatorDefault, 0) else { return false } + return (cf.takeRetainedValue() as? Bool) ?? false + } +} diff --git a/PowerController.swift b/PowerController.swift new file mode 100644 index 0000000..8d01b70 --- /dev/null +++ b/PowerController.swift @@ -0,0 +1,69 @@ +import Foundation + +struct BatteryStatus { + let onBattery: Bool + let discharging: Bool + let percent: Int +} + +enum ToggleResult: Equatable { + case ok + case grantMissing + case failed(String) +} + +@MainActor +final class PowerController { + func readSleepDisabled() -> Bool { + let out = ShellRunner.capture("/usr/bin/pmset", ["-g"]) + for line in out.split(whereSeparator: { $0 == "\n" }) { + if line.range(of: "SleepDisabled", options: .caseInsensitive) != nil { + let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" }) + if let last = toks.last { return last == "1" } + } + } + return false + } + + func batteryStatus() -> BatteryStatus { + let out = ShellRunner.capture("/usr/bin/pmset", ["-g", "batt"]) + let onBattery = out.contains("Battery Power") + let discharging = out.range(of: "discharging", options: .caseInsensitive) != nil + var percent = 100 + for tok in out.split(whereSeparator: { " \t\n;".contains($0) }) { + if tok.hasSuffix("%"), let v = Int(tok.dropLast()) { + percent = v + break + } + } + return BatteryStatus(onBattery: onBattery, discharging: discharging, percent: percent) + } + + // Put the display(s) to sleep immediately. This is an ACTION verb (not a setting + // change), so unlike `pmset -a disablesleep`, it needs no root and no sudoers grant — + // it only asks powerd to idle the display now. The system stays awake; only the panel + // goes dark, so a kept-awake Mac with the lid closed draws no display power. + func sleepDisplayNow() { + let res = ShellRunner.run("/usr/bin/pmset", ["displaysleepnow"], stdinNull: true, timeout: 3) + if res.exit != 0 { + let err = res.err.trimmingCharacters(in: .whitespacesAndNewlines) + NSLog("Sleepless: pmset displaysleepnow failed: %@", err.isEmpty ? "exit \(res.exit)" : err) + } + } + + @discardableResult + func setDisableSleep(_ on: Bool) -> ToggleResult { + let res = ShellRunner.run( + "/usr/bin/sudo", + ["-n", "/usr/bin/pmset", "-a", "disablesleep", on ? "1" : "0"], + stdinNull: true + ) + if res.exit == 0 { return .ok } + if res.err.range(of: "a password is required", options: .caseInsensitive) != nil + || res.err.range(of: "not allowed", options: .caseInsensitive) != nil + || res.err.range(of: "may not run", options: .caseInsensitive) != nil { + return .grantMissing + } + return .failed(res.err.isEmpty ? "exit \(res.exit)" : res.err.trimmingCharacters(in: .whitespacesAndNewlines)) + } +} diff --git a/README.de.md b/README.de.md index 962fddd..f1610da 100644 --- a/README.de.md +++ b/README.de.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Weitere Wege | | @@ -99,7 +99,7 @@ Klicke dann auf die Tasse in der Menüleiste, lege den Schalter um und schließe Sleepless schaltet `pmset disablesleep` um (das `SleepDisabled`-Flag des Kernels), liest es zurück, sodass die Menüleiste nie lügt, und setzt es bei deinem Akku-Mindeststand, im Low Power Mode, beim Ablaufen des Timers oder beim Neustart zurück. Eine GUI-App kann kein Passwort eintippen, deshalb fügt das Installationsprogramm eine eng gefasste sudoers-Regel für **genau zwei Befehle** hinzu: ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **Lässt sich nicht ausweiten.** sudoers gleicht Argumente wörtlich ab, ohne Platzhalter. diff --git a/README.es.md b/README.es.md index f7aa1bb..dce8acc 100644 --- a/README.es.md +++ b/README.es.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Otras formas | | @@ -99,7 +99,7 @@ Luego haz clic en la taza de la barra de menús, activa el interruptor y cierra Sleepless activa `pmset disablesleep` (el indicador `SleepDisabled` del kernel), vuelve a leerlo para que la barra de menús nunca mienta, y lo revierte en tu nivel mínimo de batería, en Low Power Mode, cuando el temporizador termina o al reiniciar. Una app gráfica no puede escribir una contraseña, así que el instalador añade una regla de sudoers de alcance reducido para **exactamente dos comandos**: ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **No se puede ampliar.** sudoers coincide con los argumentos de forma literal, sin comodines. diff --git a/README.fr.md b/README.fr.md index edb43bb..799913a 100644 --- a/README.fr.md +++ b/README.fr.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Autres méthodes | | @@ -99,7 +99,7 @@ Cliquez ensuite sur la tasse dans la barre des menus, basculez l'interrupteur et Sleepless bascule `pmset disablesleep` (le drapeau `SleepDisabled` du noyau), le relit pour que la barre des menus ne mente jamais, et le rétablit à votre plancher de batterie, en mode Économie d'énergie, à la fin de la minuterie ou au redémarrage. Une application graphique ne peut pas saisir de mot de passe, alors l'installateur ajoute une règle sudoers au périmètre strict pour **exactement deux commandes** : ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **Impossible à élargir.** sudoers compare les arguments littéralement, sans jokers. diff --git a/README.ja.md b/README.ja.md index bb89c82..7cc2fdc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | その他の方法 | | @@ -99,7 +99,7 @@ brew install --cask aboudjem/tap/sleepless Sleepless は `pmset disablesleep`(カーネルの `SleepDisabled` フラグ)を切り替え、値を読み戻すのでメニューバーが嘘をつくことはなく、バッテリー下限に達したとき、Low Power Mode のとき、タイマーが切れたとき、または再起動時に元へ戻します。GUI アプリはパスワードを入力できないため、インストーラーは**ちょうど 2 つのコマンド**だけを許可する、範囲を絞った sudoers ルールを追加します。 ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **範囲を広げられません。** sudoers はワイルドカードなしで引数を文字どおり照合します。 diff --git a/README.md b/README.md index dda0727..0fbdfcb 100644 --- a/README.md +++ b/README.md @@ -47,40 +47,42 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` -| Other ways | | -|---|---| -| **Download** | Grab the [latest release](https://github.com/Aboudjem/Sleepless/releases/latest), unzip to `/Applications`, then approve it in **System Settings → Privacy & Security → Open Anyway** (it is ad-hoc signed). | -| **Build from source** | `git clone https://github.com/Aboudjem/Sleepless.git && cd Sleepless && ./install.sh` (no Gatekeeper prompt). | +| Other ways | | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Download** | Grab the [latest release](https://github.com/Aboudjem/Sleepless/releases/latest), unzip to `/Applications`, then approve it in **System Settings → Privacy & Security → Open Anyway** (it is ad-hoc signed). | +| **Build from source** | `git clone https://github.com/Aboudjem/Sleepless.git && cd Sleepless && ./install.sh` (no Gatekeeper prompt). | -Then click the cup in the menu bar, flip the switch, and close the lid. +Then click Sleepless in the menu bar, flip the switch, and close the lid. ## Features -| | | | -|---|---|---| -| ☕ | **One switch** | Click the menu-bar cup, flip the toggle. | -| ⏲️ | **Auto-off timer** | 1h or 2h with a live countdown, then off. | -| 🔋 | **Battery floor** | Auto-off at 5–50% on battery (default 15%). | -| 🪫 | **Low Power Mode** | Steps aside when LPM is on, on battery. | -| 🖥️ | **No dongle** | Lid closed, on battery. No monitor, no HDMI plug. | -| 🚀 | **Launch at login** | Optional, off by default, always starts idle. | -| 🪶 | **Tiny + native** | One AppKit file. No Dock icon, daemon, or kext. | +| | | | +| --- | ------------------------ | ------------------------------------------------------------------------------------------------ | +| 🤖 | **One switch** | Click the menu-bar agent, flip the toggle. | +| ⏲️ | **Auto-off timer** | 1h or 2h with a live countdown, then off. | +| 🔋 | **Battery floor** | Auto-off at 5–50% on battery (default 15%). | +| 🤖 | **Agent-aware auto-off** | Watches local Claude Code, Codex, and Cursor signals, then turns off when no agents are running. | +| 📡 | **No-internet auto-off** | Turns off after sustained public-internet reachability loss. | +| 🪫 | **Low Power Mode** | Steps aside when LPM is on, on battery. | +| 🖥️ | **No dongle** | Lid closed, on battery. No monitor, no HDMI plug. | +| 🚀 | **Launch at login** | Optional, off by default, always starts idle. | +| 🪶 | **Tiny + native** | Small AppKit app. No Dock icon, daemon, kext, UI scraping, or Screen Recording. | -**Menu-bar glyph:** empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live). +**Menu-bar glyph:** outline agent = off · filled agent = awake · filled agent + dot = awake on battery (auto-off live). ## Sleepless vs the alternatives -| | **Sleepless** | Amphetamine | KeepingYouAwake | `caffeinate` | -|---|:---:|:---:|:---:|:---:| -| Awake, lid closed, no monitor | ✅ ¹ | ⚠️ ² | ❌ ³ | ❌ | -| On battery | ✅ | ✅ | ✅ lid open | ⚠️ ⁴ | -| Auto-off timer | ✅ | ✅ | ✅ | ❌ | -| Auto-off on low battery | ✅ | ✅ | ✅ | ❌ | -| Open source | ✅ MIT | ❌ App Store | ✅ MIT | Apple | -| Cost | Free | Free | Free | Free | +| | **Sleepless** | Amphetamine | KeepingYouAwake | `caffeinate` | +| ----------------------------- | :------------------: | :----------: | :-------------: | :----------: | +| Awake, lid closed, no monitor | ✅ ¹ | ⚠️ ² | ❌ ³ | ❌ | +| On battery | ✅ | ✅ | ✅ lid open | ⚠️ ⁴ | +| Auto-off timer | ✅ | ✅ | ✅ | ❌ | +| Auto-off on low battery | ✅ | ✅ | ✅ | ❌ | +| Open source | ✅ MIT | ❌ App Store | ✅ MIT | Apple | +| Cost | Free | Free | Free | Free | As of 2026-06. ¹ Uses `pmset disablesleep` and reads the flag back; behavior is hardware/macOS-version dependent. ² Documents closed-display mode but is widely reported to fail on Apple Silicon on power-source changes ([AE #28](https://github.com/x74353/Amphetamine-Enhancer/issues/28)); the app is closed source. ³ Can't do lid-closed by design, it wraps `caffeinate` ([#66](https://github.com/newmarcel/KeepingYouAwake/issues/66)). ⁴ `caffeinate -i` runs on battery; `-s` is AC-only. @@ -96,16 +98,18 @@ Then click the cup in the menu bar, flip the switch, and close the lid. ## How it works -Sleepless toggles `pmset disablesleep` (the kernel's `SleepDisabled` flag), reads it back so the menu bar never lies, and reverts it at your battery floor, in Low Power Mode, when the timer ends, or on reboot. A GUI app can't type a password, so the installer adds a scoped sudoers rule for **exactly two commands**: +Sleepless toggles `pmset disablesleep` (the kernel's `SleepDisabled` flag), reads it back so the menu bar never lies, and reverts it at your battery floor, in Low Power Mode, when the timer ends, when an enabled agent/internet cutoff fires, or on reboot. A GUI app can't type a password, so the installer adds a scoped sudoers rule for **exactly two commands**: ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **Can't be widened.** sudoers matches arguments literally, no wildcards. -- **Nothing to hijack.** No daemon, no helper script, no shell. It calls `/usr/bin/pmset` directly. +- **Nothing privileged to hijack.** No daemon and no privileged helper script. The ongoing keep-awake toggle calls `/usr/bin/pmset` directly with an argv array. - **Always reversible.** Reboot, the floor, the timer, or `./uninstall.sh` (which proves the grant is gone). +Agent monitoring is local-only: bounded CLI/app detection, user-owned processes, and optional heartbeat hooks for tools that need stronger signals. Sleepless does not scrape app windows, request Screen Recording, or monitor vendor cloud agents with no local signal. Internet auto-off uses macOS network path status plus a lightweight HTTPS reachability probe, and both new cutoffs default off until you enable them. + Verify a download, no Apple account needed: ```sh @@ -121,36 +125,42 @@ Full threat model, the App Store verdict, and the audit guide: [SECURITY.md](SEC Does pmset disablesleep still work on Apple Silicon (M1/M2/M3)? Yes. `pmset -a disablesleep 1` sets the kernel's `SleepDisabled` flag on Apple Silicon, confirmed firsthand on macOS 26.3, which keeps the Mac awake with the lid closed on battery. Verify with `pmset -g | grep SleepDisabled` (it should read `1`). Claims that it "stopped working" usually describe `caffeinate` or caffeinate-based apps, a different mechanism. +
Why does my Mac sleep on lid close even with Amphetamine or KeepingYouAwake? Those use macOS power assertions, which stop the idle timer but can't override the hardware lid-close trigger. KeepingYouAwake wraps `caffeinate`, which can't do lid-closed ([#66](https://github.com/newmarcel/KeepingYouAwake/issues/66)). `pmset disablesleep`, which Sleepless uses, can. +
Is it safe? Will it overheat or drain the battery? It is safe for light unattended work (downloads, syncs, a hotspot). Heavy sustained load with the lid fully shut reduces airflow, so use judgement. The battery floor, Low Power Mode auto-off, and the timer all stop it before it drains the Mac. +
Does it need sudo, a kernel extension, or a daemon? -One tightly scoped `sudo` grant (two exact `pmset` commands) so a GUI app can flip the setting without a prompt. No kernel extension, no daemon. The whole app is a single AppKit file. +One tightly scoped `sudo` grant (two exact `pmset` commands) so a GUI app can flip the setting without a prompt. No kernel extension, no daemon, and no privileged helper. +
How do I stop it or remove it? Flip the switch off, or let the timer or battery floor do it, and normal sleep returns. A reboot also resets it. `./uninstall.sh` removes the app, login item, and the sudoers grant, then proves the grant is gone. +
Why isn't it notarized? It is a personal open-source tool with no paid Apple Developer ID, so it is ad-hoc signed. Build from source to skip Gatekeeper, or use **Open Anyway** for the prebuilt app. The notarization steps are documented in [docs/AUDIT.md](docs/AUDIT.md). +
## Contributing diff --git a/README.zh-CN.md b/README.zh-CN.md index 19b66ff..3b97147 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | 其他方式 | | @@ -101,7 +101,7 @@ brew install --cask aboudjem/tap/sleepless Sleepless 切换 `pmset disablesleep`(内核的 `SleepDisabled` 标志),把它读回来让菜单栏绝不撒谎,并在到达你的电量下限、进入 Low Power Mode、定时器结束或重启时把它还原。GUI 应用没法输入密码,所以安装程序会加一条范围严格限定的 sudoers 规则,**只允许两条命令**: ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` - **无法被放宽。** sudoers 按字面匹配参数,没有通配符。 diff --git a/SECURITY.md b/SECURITY.md index a3b0264..01b8471 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,18 +36,21 @@ reality rather than assuming the command worked. ## The passwordless grant — exactly what it permits A GUI app has no terminal to type a password into, so Sleepless runs `pmset` through a -tightly scoped `/etc/sudoers.d` drop-in. `install.sh` writes this (with your username -substituted for `__USER__`), owned `root:wheel`, mode `0440`: +tightly scoped `/etc/sudoers.d` drop-in. The app's one-time native setup, `install.sh`, +and `grant.sh` all install the same rule (with your numeric UID substituted for `__UID__`), +owned `root:wheel`, mode `0440`: ``` - ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 ``` -**This grant lets one user run, as root, exactly two fully-specified commands and nothing -else.** sudoers matches command arguments *literally* — and this rule contains **no -wildcards** — so the match is total. From the sudoers manual: *"If a Cmnd has associated +For example, a typical first local account might install as `#501 ALL=(root) ...`. + +**This grant lets one UID run, as root, exactly two fully-specified commands and nothing +else.** sudoers matches command arguments _literally_ — and this rule contains **no +wildcards** — so the match is total. From the sudoers manual: _"If a Cmnd has associated command line arguments, then the arguments in the Cmnd must match exactly those given by -the user on the command line (or match the wildcards if there are any)."* +the user on the command line (or match the wildcards if there are any)."_ Consequences you can rely on: @@ -57,16 +60,21 @@ Consequences you can rely on: - Sleepless calls `sudo` with an **argv array**, not a shell string (`Process.arguments` in `App.swift`), so there is no `/bin/sh -c`, no command substitution, and no word-splitting surface inside the app. -- There is **no helper script**. The classic sudoers footgun is a *user-writable* script - that root executes — rewrite it, get root. Sleepless points the rule directly at Apple's - `/usr/bin/pmset`, and the sudoers file itself is `root:wheel 0440` (you cannot modify it - without `sudo`). Both mitigations are exactly what the literature prescribes. +- The ongoing passwordless grant points directly at Apple's `/usr/bin/pmset`, not at a + helper script. The classic sudoers footgun is a _user-writable_ script that root executes + on every privileged action — rewrite it, get root. Sleepless avoids that: the rule itself + is `root:wheel 0440`, has no wildcards, and can only invoke the two `pmset` argument + vectors above. +- During the app's one-time native setup, the root-authenticated command is generated from + constants baked into the app binary and validated with `visudo` before installation; it + does **not** execute the bundled `grant.sh` as root. `grant.sh` remains available for + manual installs from a clone or app bundle. ## Honest residual risk The grant is passwordless **by design**: any process already running as your user can flip the sleep flag silently. We are not pretending the attack surface is zero. But the worst -case is *"your Mac was kept awake, or allowed to sleep."* It is **not** data exfiltration +case is _"your Mac was kept awake, or allowed to sleep."_ It is **not** data exfiltration and **not** root code execution — the two pinned arguments to one Apple binary do not provide either. @@ -83,10 +91,27 @@ Sleepless adds a second belt-and-suspenders: a **battery-floor auto-off** (defau that flips the flag back to `0` while the Mac is awake and discharging, so a forgotten "on" state can't drain the battery to empty. +## Agent and internet monitoring + +The agent-aware cutoff is local-only. Sleepless looks for bounded, user-owned local signals: +validated CLI tools, known app bundle IDs, process/session signals, and optional heartbeat +hooks for tools that need a stronger signal. It does **not** scrape windows, read screen +contents, request Screen Recording, use Accessibility APIs, or poll vendor cloud agents that +have no local worker/session signal. + +The no-internet cutoff uses macOS network path status plus a small HTTPS reachability probe. +It acts only after a grace period, and the feature is opt-in. These checks do not change the +sudoers grant: the only privileged commands remain the two `pmset disablesleep` toggles above. + +Agent setup writes local diagnostics to +`~/Library/Caches/com.aboudjem.Sleepless/setup-diagnostics.jsonl`. The JSON Lines log is local +only, rotates at a small size, redacts your home path, and is meant for debugging hook setup +failures. + ## Code signing, notarization, and Gatekeeper Sleepless is **ad-hoc signed and not notarized** — it has no paid Apple Developer ID. The -trust model is *read the source, build it yourself*. (Notarization is also not a malware +trust model is _read the source, build it yourself_. (Notarization is also not a malware guarantee: signed, notarized macOS stealers have shipped.) - **Build from source (recommended):** locally compiled apps are **not quarantined**, so diff --git a/ShellRunner.swift b/ShellRunner.swift new file mode 100644 index 0000000..b6843ee --- /dev/null +++ b/ShellRunner.swift @@ -0,0 +1,54 @@ +import Foundation + +struct CommandResult { + let exit: Int32 + let out: String + let err: String +} + +enum ShellRunner { + static let safePath = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin" + + @discardableResult + static func run(_ launchPath: String, _ args: [String], stdinNull: Bool = false, timeout: TimeInterval? = nil) -> CommandResult { + let process = Process() + process.executableURL = URL(fileURLWithPath: launchPath) + process.arguments = args + var env = ProcessInfo.processInfo.environment + env["PATH"] = safePath + env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path + process.environment = env + + let outPipe = Pipe(), errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + if stdinNull { process.standardInput = FileHandle.nullDevice } + + do { + try process.run() + } catch { + NSLog("Sleepless: failed to launch %@: %@", launchPath, error.localizedDescription) + return CommandResult(exit: -1, out: "", err: "launch failed: \(error.localizedDescription)") + } + + if let timeout { + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) { + if process.isRunning { process.terminate() } + } + } + + let outData = outPipe.fileHandleForReading.readDataToEndOfFile() + let errData = errPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return CommandResult( + exit: process.terminationStatus, + out: String(data: outData, encoding: .utf8) ?? "", + err: String(data: errData, encoding: .utf8) ?? "" + ) + } + + @discardableResult + static func capture(_ launchPath: String, _ args: [String], timeout: TimeInterval? = nil) -> String { + run(launchPath, args, timeout: timeout).out + } +} diff --git a/build.sh b/build.sh index 156d041..10e7b3a 100755 --- a/build.sh +++ b/build.sh @@ -16,6 +16,7 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_NAME="Sleepless" +EXECUTABLE_NAME="Sleepless" # macOS arm64 target. Sleepless is verified on macOS 26 (Tahoe) / Apple Silicon. # Override with TARGET=... (e.g. CI on a runner whose SDK predates macOS 26). TARGET="${TARGET:-arm64-apple-macos26.0}" @@ -42,40 +43,51 @@ echo " target: $TARGET" command -v swiftc >/dev/null || { echo "error: swiftc not found. Install the Command Line Tools: xcode-select --install" >&2; exit 1; } # 1. Optionally regenerate the icon from the SF Symbol (needs a GUI session for AppKit). -ICNS="$REPO/assets/$APP_NAME.icns" +ICNS="$REPO/assets/$EXECUTABLE_NAME.icns" if [ "$REGEN_ICON" = "1" ]; then echo "==> Regenerating icon from make-icon.swift" TMP_ICON="$(mktemp -d)" swiftc -O -framework AppKit "$REPO/make-icon.swift" -o "$TMP_ICON/mkicon" "$TMP_ICON/mkicon" "$TMP_ICON" - iconutil -c icns "$TMP_ICON/$APP_NAME.iconset" -o "$REPO/assets/$APP_NAME.icns" + iconutil -c icns "$TMP_ICON/$EXECUTABLE_NAME.iconset" -o "$REPO/assets/$EXECUTABLE_NAME.icns" rm -rf "$TMP_ICON" fi [ -f "$ICNS" ] || { echo "error: missing $ICNS (run ./build.sh --regen-icon)" >&2; exit 1; } # 2. Compile the executable. -echo "==> Compiling App.swift" +echo "==> Compiling Swift sources" BIN_TMP="$(mktemp -d)" -swiftc -O -parse-as-library -target "$TARGET" -framework AppKit -framework ServiceManagement \ - "$REPO/App.swift" -o "$BIN_TMP/$APP_NAME" +swiftc -O -parse-as-library -target "$TARGET" \ + -framework AppKit -framework ServiceManagement -framework Network \ + -framework IOKit \ + "$REPO/AppLogger.swift" \ + "$REPO/ShellRunner.swift" \ + "$REPO/PowerController.swift" \ + "$REPO/AgentMonitor.swift" \ + "$REPO/ConnectivityMonitor.swift" \ + "$REPO/LidMonitor.swift" \ + "$REPO/App.swift" \ + -o "$BIN_TMP/$EXECUTABLE_NAME" # 3. Assemble the bundle: Contents/{Info.plist, MacOS/, Resources/.icns} echo "==> Assembling bundle" rm -rf "$APP" +rm -rf "$DEST/Sleepless.app" mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" cp "$REPO/Info.plist" "$CONTENTS/Info.plist" -cp "$BIN_TMP/$APP_NAME" "$CONTENTS/MacOS/$APP_NAME" -cp "$ICNS" "$CONTENTS/Resources/$APP_NAME.icns" -chmod +x "$CONTENTS/MacOS/$APP_NAME" +cp "$BIN_TMP/$EXECUTABLE_NAME" "$CONTENTS/MacOS/$EXECUTABLE_NAME" +cp "$ICNS" "$CONTENTS/Resources/$EXECUTABLE_NAME.icns" +chmod +x "$CONTENTS/MacOS/$EXECUTABLE_NAME" # Ship the grant + uninstall scripts inside the bundle so Homebrew-cask users (who get # only the .app) can run the one-time passwordless grant and a clean uninstall. -cp "$REPO/grant.sh" "$REPO/uninstall.sh" "$CONTENTS/Resources/" -chmod +x "$CONTENTS/Resources/grant.sh" "$CONTENTS/Resources/uninstall.sh" +cp "$REPO/grant.sh" "$REPO/uninstall.sh" "$REPO/reset-agent-setup.sh" "$CONTENTS/Resources/" +chmod +x "$CONTENTS/Resources/grant.sh" "$CONTENTS/Resources/uninstall.sh" "$CONTENTS/Resources/reset-agent-setup.sh" rm -rf "$BIN_TMP" -# 4. Ad-hoc sign (no Apple Developer ID needed; trust comes from building it yourself). +# 4. Ad-hoc sign with hardened runtime enabled (no Apple Developer ID needed; trust +# comes from building it yourself). echo "==> Ad-hoc signing" -codesign --force --deep --sign - "$APP" +codesign --force --deep --options runtime --sign - "$APP" codesign --verify --verbose=1 "$APP" 2>&1 | sed 's/^/ /' || true echo "" diff --git a/docs/AUDIT.md b/docs/AUDIT.md index 8fa12b9..becb0da 100644 --- a/docs/AUDIT.md +++ b/docs/AUDIT.md @@ -2,7 +2,7 @@ Sleepless asks for a narrow slice of root, so it should be easy to check, not taken on faith. This page is the practical companion to [SECURITY.md](../SECURITY.md): the latter -explains *why* the design is safe, this one shows you *how to confirm it yourself* and how +explains _why_ the design is safe, this one shows you _how to confirm it yourself_ and how to verify a download you did not build. There is no Apple account behind any of this. Every check below is free and runs on your @@ -10,14 +10,15 @@ machine. ## Read it in about ten minutes -The whole app is one file. To satisfy yourself it does what it claims and nothing else: +The app is a small set of Swift files. To satisfy yourself it does what it claims and nothing else, read: -| Read | What you are checking | -|---|---| -| [`App.swift`](../App.swift) | The only thing it runs as root is `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). No network calls, no file writes outside `UserDefaults`, no shell strings. | -| [`sleepless.sudoers.template`](../sleepless.sudoers.template) / [`grant.sh`](../grant.sh) | The passwordless grant permits exactly those two fully-specified commands, no wildcards, installed `root:wheel 0440`. | -| [`build.sh`](../build.sh) | `swiftc` + a hand-assembled, ad-hoc-signed bundle. No downloaded blobs, no install-time scripts baked into the binary. | -| [`uninstall.sh`](../uninstall.sh) | Removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset …` prompts again. | +- [`App.swift`](../App.swift) and [`PowerController.swift`](../PowerController.swift): steady-state privilege is only `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). The one-time native setup generates the sudoers drop-in from binary constants, validates it with `visudo`, and does not run bundled scripts as root. +- [`AgentMonitor.swift`](../AgentMonitor.swift): agent detection is bounded and local-only: CLI validation, known app bundle IDs, user-owned processes, and optional heartbeat files. No UI scraping, Screen Recording, Accessibility, or cloud-agent polling. +- [`AppLogger.swift`](../AppLogger.swift): setup diagnostics are written only to a small rotating JSON Lines cache under `~/Library/Caches/com.aboudjem.Sleepless/`. +- [`ConnectivityMonitor.swift`](../ConnectivityMonitor.swift): no-internet auto-off uses macOS network path status plus a lightweight HTTPS reachability probe and does not affect the privileged grant. +- [`sleepless.sudoers.template`](../sleepless.sudoers.template) and [`grant.sh`](../grant.sh): manual setup path: the passwordless grant permits exactly those two fully-specified commands for the local numeric UID (`#501`-style), has no wildcards, and installs `root:wheel 0440`. +- [`build.sh`](../build.sh): `swiftc` + a hand-assembled, ad-hoc-signed bundle with hardened runtime enabled. No downloaded blobs, no install-time scripts baked into the binary. +- [`uninstall.sh`](../uninstall.sh): removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset ...` prompts again. The single privileged file on your system is `/etc/sudoers.d/sleepless-disablesleep`. Read it, and `sudo rm` it any time to revoke everything. @@ -39,7 +40,7 @@ gh attestation verify Sleepless-.zip -R Aboudjem/Sleepless What each one proves: - **`shasum -c`** proves the file was not altered after publishing. It says nothing about - *who* built it, so it is necessary but not sufficient on its own. + _who_ built it, so it is necessary but not sufficient on its own. - **`gh attestation verify`** proves the file came out of this project's release workflow (a specific repository + commit + workflow), cryptographically, with no shared secret to leak. This is the strong link from "the source you can read" to "the binary you ran." It @@ -61,11 +62,13 @@ cd Sleepless && git checkout v # Rebuild the executable with the release's deployment target. swiftc -O -parse-as-library -target arm64-apple-macos13.0 \ - -framework AppKit -framework ServiceManagement App.swift -o /tmp/Sleepless-rebuilt + -framework AppKit -framework ServiceManagement -framework Network \ + ShellRunner.swift PowerController.swift AgentMonitor.swift ConnectivityMonitor.swift App.swift \ + -o /tmp/Sleepless-rebuilt # Unzip the release and compare the Mach-O inside the bundle. ditto -x -k Sleepless-.zip /tmp/rel -shasum -a 256 /tmp/Sleepless-rebuilt /tmp/rel/Sleepless.app/Contents/MacOS/Sleepless +shasum -a 256 /tmp/Sleepless-rebuilt "/tmp/rel/Sleepless.app/Contents/MacOS/Sleepless" ``` Caveats, stated honestly: @@ -74,7 +77,7 @@ Caveats, stated honestly: release runner (`macos-latest`). A different compiler version will produce a different, still-correct binary. The release job prints its toolchain in the **Toolchain** step so you can match it. -- The **signed** `.app` is only *likely* reproducible: ad-hoc code signatures embed +- The **signed** `.app` is only _likely_ reproducible: ad-hoc code signatures embed non-deterministic data, so compare the unsigned Mach-O above, not the signed bundle. See the [Reproducible Builds definition](https://reproducible-builds.org/docs/definition/). @@ -94,11 +97,11 @@ curl -s --request POST --url https://www.virustotal.com/api/v3/files \ # …then open the returned analysis URL, or just drag the zip onto virustotal.com. ``` -Note: ad-hoc-signed, unnotarized binaries draw more *heuristic* flags than notarized ones, so +Note: ad-hoc-signed, unnotarized binaries draw more _heuristic_ flags than notarized ones, so read any detection in context. A clean result is reassuring, not absolute; pair it with the attestation above. -**Public VirusTotal report (v1.1.0):** https://www.virustotal.com/gui/file/30a43590629b6a3cd2e1610c249c137c4b235a5f319ce8d8a9e866c1fd914cde +**Public VirusTotal report (v1.1.0):** [VirusTotal permalink](https://www.virustotal.com/gui/file/30a43590629b6a3cd2e1610c249c137c4b235a5f319ce8d8a9e866c1fd914cde) That is the permalink for the v1.1.0 zip (the SHA-256 matches `SHA256SUMS`). It goes live once the file is submitted to VirusTotal. Browser submission requires a one-time reCAPTCHA, so submit it from your browser (drag the zip onto virustotal.com) or with the API. @@ -115,16 +118,16 @@ xcrun notarytool store-credentials "notarytool-password" \ # Re-sign with a Developer ID cert + hardened runtime + secure timestamp. codesign --force --options runtime --timestamp \ - --sign "Developer ID Application: ()" Sleepless.app + --sign "Developer ID Application: ()" "Sleepless.app" -ditto -c -k --keepParent Sleepless.app Sleepless.zip +ditto -c -k --keepParent "Sleepless.app" Sleepless.zip xcrun notarytool submit Sleepless.zip --keychain-profile "notarytool-password" --wait -xcrun stapler staple Sleepless.app +xcrun stapler staple "Sleepless.app" ``` Prerequisite: [Apple Developer Program, $99/yr](https://developer.apple.com/programs/whats-included/), and a "Developer ID Application" certificate. Notarization removes the "Apple could not verify this app" first-launch block; it does not change anything about how the app works. The `/etc/sudoers.d` install step is what makes Sleepless ineligible for the -Mac App **Store**, but it does not block notarized *direct* distribution (notarization is an +Mac App **Store**, but it does not block notarized _direct_ distribution (notarization is an automated malware scan, not a behavioral policy review). diff --git a/docs/LAUNCH.md b/docs/LAUNCH.md index a03e109..09e5e2c 100644 --- a/docs/LAUNCH.md +++ b/docs/LAUNCH.md @@ -126,7 +126,7 @@ fresh launch window. - **Name:** Sleepless - **Tagline (≤60 chars):** `Keep your Mac awake with the lid closed` - **Assets:** gallery images **1270×760**, thumbnail **240×240** (reuse the violet brand + - coffee cup; `assets/social-preview.png` is a good base), a short demo (use `assets/demo.gif`). + coffee-cup mark; `assets/social-preview.png` is a good base), a short demo (use `assets/demo.gif`). - **Topics:** Mac, Developer Tools, Productivity, Open Source. - **First maker comment:** @@ -171,6 +171,7 @@ fresh launch window. --- ### Reminder + Do not post any of these from an automated process. They require your own accounts and ongoing presence in each community. Lead with the demo, be candid about the undocumented mechanism and the ad-hoc signing, and respond quickly. diff --git a/docs/LISTINGS.md b/docs/LISTINGS.md index 3ed6381..0e03429 100644 --- a/docs/LISTINGS.md +++ b/docs/LISTINGS.md @@ -132,11 +132,11 @@ Self-post; promotion is welcomed here. Lead with the demo GIF. ### r/swift Frame as an engineering write-up (90/10 etiquette: mostly substance, light promo). Read the sidebar first. -> **Title:** How I keep a MacBook awake lid-closed from a single-file AppKit menu-bar app (`pmset disablesleep` + a scoped sudoers grant) +> **Title:** How I keep a MacBook awake lid-closed from a tiny AppKit menu-bar app (`pmset disablesleep` + a scoped sudoers grant) > **Body:** A short write-up of the mechanism (`pmset disablesleep` sets the kernel > `SleepDisabled` flag, unlike `caffeinate` which can't override lid-close), how a GUI app runs > it passwordless via a tightly scoped `/etc/sudoers.d` rule (two exact commands, no wildcards), -> and the Swift 6 single-file `@main` setup. Source: https://github.com/Aboudjem/Sleepless +> and the small native Swift/AppKit setup. Source: https://github.com/Aboudjem/Sleepless ### MacRumors macOS Apps forum Evergreen thread, ranks in Google. Post in the macOS Apps forum with the demo + a short, diff --git a/docs/adr/0001-local-agent-detection.md b/docs/adr/0001-local-agent-detection.md new file mode 100644 index 0000000..090aa63 --- /dev/null +++ b/docs/adr/0001-local-agent-detection.md @@ -0,0 +1,3 @@ +# Local Agent Detection + +Sleepless monitors only locally observable coding-agent work: CLI processes, local workers, session signals, or app-wide hook/heartbeat integrations. It deliberately avoids UI scraping, broad filesystem searches, Screen Recording, and cloud-only vendor API monitoring because agent auto-off is a power-control safety feature; guessing from private UI or remote state would be brittle, privacy-sensitive, and easy to misrepresent to users. Tools participate only when Sleepless has a bounded, reliable detector for them, and app-wide integrations are required only when default local detection is not reliable enough. diff --git a/grant.sh b/grant.sh index 39559b1..10fe8d5 100755 --- a/grant.sh +++ b/grant.sh @@ -9,31 +9,36 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SUDOERS_DST="/etc/sudoers.d/sleepless-disablesleep" -# Resolve the REAL user. Prefer SLEEPLESS_USER (the app passes it, because under the native -# auth sheet this script runs as root with SUDO_USER unset), then SUDO_USER, then the caller. -USER_NAME="${SLEEPLESS_USER:-${SUDO_USER:-$(id -un)}}" +# Resolve the target user. Prefer SLEEPLESS_USER for manual overrides, then SUDO_USER, +# then the caller. +USER_NAME="${SLEEPLESS_USER:-${SUDO_USER:-$(/usr/bin/id -un)}}" # Never install a root-owned grant (it is useless and not what the user wants): if we somehow # resolved to root/empty, fall back to the GUI console user, and refuse if still unresolved. if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "root" ]; then - USER_NAME="$(stat -f%Su /dev/console 2>/dev/null || true)" + USER_NAME="$(/usr/bin/stat -f%Su /dev/console 2>/dev/null || true)" fi if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "root" ]; then echo "error: could not resolve a non-root user for the grant; refusing to install." >&2 exit 1 fi +USER_UID="$(/usr/bin/id -u "$USER_NAME" 2>/dev/null || true)" +if [[ ! "$USER_UID" =~ ^[0-9]+$ ]] || [ "$USER_UID" = "0" ]; then + echo "error: could not resolve a non-root UID for '$USER_NAME'; refusing to install." >&2 + exit 1 +fi -# Run privileged steps with sudo normally, but directly when we are ALREADY root (e.g. the -# app installs this via one native macOS auth sheet, so there is no Terminal + no sudo prompt). -SUDO="sudo" -[ "$(id -u)" -eq 0 ] && SUDO="" +# Run privileged steps with sudo normally, but directly when we are already root. +SUDO=(/usr/bin/sudo) +[ "$(/usr/bin/id -u)" -eq 0 ] && SUDO=() # Source of truth for the grant line: the repo template if present, else the identical # inline string (when this script ships inside the .app bundle, no template is alongside). TEMPLATE="$SCRIPT_DIR/sleepless.sudoers.template" if [ -f "$TEMPLATE" ]; then - GRANT="$(sed "s/__USER__/$USER_NAME/" "$TEMPLATE")" + GRANT="$(< "$TEMPLATE")" + GRANT="${GRANT//__UID__/$USER_UID}" else - GRANT="$USER_NAME ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1" + GRANT="#$USER_UID ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1" fi echo "Sleepless will install this passwordless grant at $SUDOERS_DST (root:wheel, 0440):" @@ -46,13 +51,13 @@ if [ "${1:-}" != "--yes" ] && [ "${1:-}" != "-y" ]; then case "$reply" in [yY]*) ;; *) echo "Aborted."; exit 1 ;; esac fi -TMP="$(mktemp)" -printf '%s\n' "$GRANT" > "$TMP" -if ! $SUDO visudo -cf "$TMP" >/dev/null; then +TMP="$(/usr/bin/mktemp)" +trap '/bin/rm -f "$TMP"' EXIT +/usr/bin/printf '%s\n' "$GRANT" > "$TMP" +if ! "${SUDO[@]}" /usr/sbin/visudo -cf "$TMP" >/dev/null; then echo "error: generated sudoers failed validation; not installing." >&2 - rm -f "$TMP"; exit 1 + exit 1 fi -$SUDO install -m 0440 -o root -g wheel "$TMP" "$SUDOERS_DST" -rm -f "$TMP" -$SUDO visudo -c >/dev/null && echo "✅ grant installed and sudoers parses cleanly ($SUDOERS_DST)." +"${SUDO[@]}" /usr/bin/install -m 0440 -o root -g wheel "$TMP" "$SUDOERS_DST" +"${SUDO[@]}" /usr/sbin/visudo -c >/dev/null && echo "✅ grant installed and sudoers parses cleanly ($SUDOERS_DST)." echo " Toggle Sleepless from the menu bar; it will no longer need a password." diff --git a/install.sh b/install.sh index 3b186c8..73dbe54 100755 --- a/install.sh +++ b/install.sh @@ -12,7 +12,11 @@ APP="/Applications/$APP_NAME.app" BUNDLE_ID="com.aboudjem.Sleepless" SUDOERS_DST="/etc/sudoers.d/sleepless-disablesleep" LAUNCH_AGENT="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist" -USER_NAME="$(id -un)" +USER_UID="$(id -u)" +RESET_AGENT_SETUP=0 +if [ ! -d "$APP" ] && [ ! -d "/Applications/Sleepless.app" ]; then + RESET_AGENT_SETUP=1 +fi echo "Sleepless installer" echo "===================" @@ -21,7 +25,7 @@ echo " 1. Build $APP_NAME.app and copy it to /Applications." echo " 2. Install a passwordless sudo grant at $SUDOERS_DST so the app can flip" echo " lid-close sleep without prompting. The grant (root:wheel, 0440) is EXACTLY:" echo "" -echo " $USER_NAME ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1" +echo " #$USER_UID ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1" echo "" echo " That is the only thing it permits — turn lid-close sleep on or off. Nothing else." echo " 3. Add a login item (~/Library/LaunchAgents/$BUNDLE_ID.plist) so it starts at login." @@ -56,10 +60,16 @@ PLIST launchctl bootout "gui/$(id -u)/$BUNDLE_ID" 2>/dev/null || true launchctl bootstrap "gui/$(id -u)" "$LAUNCH_AGENT" 2>/dev/null || true +# Reset per-user agent detector setup on clean installs so uninstall -> install +# shows setup again instead of trusting stale hooks from a removed app. +if [ "$RESET_AGENT_SETUP" = "1" ]; then + "$REPO/reset-agent-setup.sh" +fi + # Launch now. open "$APP" echo "" -echo "✅ Installed. The coffee cup is in your menu bar — click it to toggle." +echo "✅ Installed. Sleepless is in your menu bar — click it to toggle." echo " Turn ON, close the lid: your Mac stays awake on battery (auto-off at the floor you set)." echo " To remove everything (including the grant): ./uninstall.sh" diff --git a/reset-agent-setup.sh b/reset-agent-setup.sh new file mode 100755 index 0000000..b8844b7 --- /dev/null +++ b/reset-agent-setup.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# reset-agent-setup.sh — remove only Sleepless' local agent detector hooks/state. +# +# This leaves the user's agent tool configs intact, except for hook commands that point at +# Sleepless' heartbeat helper. +set -uo pipefail + +BUNDLE_ID="com.aboudjem.Sleepless" +MARKER=".sleepless/agents/heartbeat.sh" + +echo "==> Resetting Sleepless agent detector setup" + +if command -v python3 >/dev/null 2>&1; then + MARKER="$MARKER" python3 <<'PY' +import json +import os +import stat +import sys + +marker = os.environ["MARKER"] +home = os.path.expanduser("~") +configs = [ + os.path.join(home, ".claude", "settings.json"), + os.path.join(home, ".codex", "hooks.json"), + os.path.join(home, ".cursor", "hooks.json"), +] + +def command_matches(value): + return isinstance(value, str) and marker in value + +def prune_entry(entry): + if not isinstance(entry, dict): + return entry, False, False + + if command_matches(entry.get("command")): + return None, True, True + + hooks = entry.get("hooks") + if not isinstance(hooks, list): + return entry, False, False + + changed = False + kept_hooks = [] + for hook in hooks: + if isinstance(hook, dict) and command_matches(hook.get("command")): + changed = True + continue + kept_hooks.append(hook) + + if not changed: + return entry, False, False + + if not kept_hooks: + return None, True, True + + updated = dict(entry) + updated["hooks"] = kept_hooks + return updated, False, True + +for path in configs: + if not os.path.exists(path): + continue + try: + with open(path, "r", encoding="utf-8") as handle: + root = json.load(handle) + except Exception as exc: + print(f" skipped invalid JSON: {path} ({exc})", file=sys.stderr) + continue + + if not isinstance(root, dict) or not isinstance(root.get("hooks"), dict): + continue + + hooks = root["hooks"] + changed = False + for event in list(hooks.keys()): + entries = hooks.get(event) + if not isinstance(entries, list): + continue + + kept_entries = [] + for entry in entries: + updated, removed, entry_changed = prune_entry(entry) + changed = changed or removed or entry_changed + if updated is not None: + kept_entries.append(updated) + + if kept_entries: + hooks[event] = kept_entries + else: + del hooks[event] + + if not changed: + continue + + tmp = f"{path}.sleepless-reset-tmp" + with open(tmp, "w", encoding="utf-8") as handle: + json.dump(root, handle, indent=2, sort_keys=True) + handle.write("\n") + os.replace(tmp, path) + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + print(f" removed Sleepless hooks from {path}") +PY +else + echo " python3 not found; hook JSON cleanup skipped" +fi + +rm -rf "$HOME/.sleepless/agents" +rmdir "$HOME/.sleepless" 2>/dev/null || true +/usr/bin/defaults delete "$BUNDLE_ID" agentAutoOffEnabled 2>/dev/null || true diff --git a/sleepless.sudoers.template b/sleepless.sudoers.template index 2973926..d9c8b78 100644 --- a/sleepless.sudoers.template +++ b/sleepless.sudoers.template @@ -1,12 +1,12 @@ # Sleepless passwordless grant — template. # -# install.sh substitutes __USER__ with your login name ($(id -un)) and installs +# grant.sh substitutes __UID__ with your numeric user ID ($(id -u)) and installs # the result to /etc/sudoers.d/sleepless-disablesleep, owned root:wheel, mode 0440. # -# It grants ONE user the right to run, as root, EXACTLY these two fully-specified +# It grants ONE UID the right to run, as root, EXACTLY these two fully-specified # commands and NOTHING else. sudoers matches command arguments literally (there are # no wildcards here), so this grant cannot be widened by appending other flags — # `sudo pmset -a sleep 0`, `pmset restoredefaults`, etc. all fall through and demand # a password. The only thing it lets Sleepless do without a prompt is flip lid-close # sleep on (1) or off (0). See SECURITY.md for the full threat model. -__USER__ ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 +#__UID__ ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1 diff --git a/uninstall.sh b/uninstall.sh index 35370ab..964c567 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -3,14 +3,16 @@ # the login item, AND the passwordless grant. Ends by PROVING the privilege is gone. set -uo pipefail # not -e: we want to attempt every cleanup step even if one is absent +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_NAME="Sleepless" APP="/Applications/$APP_NAME.app" BUNDLE_ID="com.aboudjem.Sleepless" SUDOERS_DST="/etc/sudoers.d/sleepless-disablesleep" LAUNCH_AGENT="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist" +RESET_AGENT_SETUP="$SCRIPT_DIR/reset-agent-setup.sh" echo "Sleepless uninstaller" -echo "=====================" +echo "============================" # 1. Restore normal sleep BEFORE removing the grant (a reboot would also reset it to 0). echo "==> Restoring normal sleep (disablesleep 0)" @@ -25,13 +27,25 @@ rm -f "$LAUNCH_AGENT" # 3. Remove the app. echo "==> Removing $APP" rm -rf "$APP" +rm -rf "/Applications/Sleepless.app" # 4. Remove the passwordless grant (password required, by design — you're touching sudo). echo "==> Removing passwordless grant (you may be asked for your password)" sudo rm -f "$SUDOERS_DST" sudo visudo -c >/dev/null && echo " sudoers still parses cleanly" -# 5. Proof of revocation: the previously-passwordless command must now PROMPT. +# 5. Remove Sleepless' per-user agent detector hooks/state so reinstall starts from setup. +if [ -x "$RESET_AGENT_SETUP" ]; then + "$RESET_AGENT_SETUP" +else + echo "==> Resetting Sleepless agent detector setup" + rm -rf "$HOME/.sleepless/agents" + rmdir "$HOME/.sleepless" 2>/dev/null || true + /usr/bin/defaults delete "$BUNDLE_ID" agentAutoOffEnabled 2>/dev/null || true + echo " reset helper state; hook JSON cleanup unavailable ($RESET_AGENT_SETUP missing)" +fi + +# 6. Proof of revocation: the previously-passwordless command must now PROMPT. echo "==> Verifying the grant is gone" sudo -k if sudo -n /usr/bin/pmset -a disablesleep 0 2>/dev/null; then @@ -41,5 +55,5 @@ else fi echo "" -echo "Done. Sleepless and its grant are removed. UserDefaults (the battery-floor value)" -echo "can be cleared with: defaults delete $BUNDLE_ID 2>/dev/null || true" +echo "Done. Sleepless, its grant, login item, and agent detector setup are removed." +echo "UserDefaults (such as the battery-floor value) can be cleared with: defaults delete $BUNDLE_ID 2>/dev/null || true" From bf31cdab81975d50d1e9904ba433c4528c499364 Mon Sep 17 00:00:00 2001 From: Farnood Date: Sun, 7 Jun 2026 17:02:47 -0400 Subject: [PATCH 2/2] Keep functional split on existing branding Co-authored-by: Cursor --- .github/workflows/ci.yml | 2 +- README.de.md | 2 +- README.es.md | 2 +- README.fr.md | 2 +- README.ja.md | 2 +- README.md | 6 +++--- README.zh-CN.md | 2 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8794f88..1bb831b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,6 @@ jobs: - name: Upload app artifact uses: actions/upload-artifact@v4 with: - name: Sleepless-Agents-app + name: Sleepless-app path: dist/Sleepless.app if-no-files-found: error diff --git a/README.de.md b/README.de.md index f1610da..13bfd0d 100644 --- a/README.de.md +++ b/README.de.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Weitere Wege | | diff --git a/README.es.md b/README.es.md index dce8acc..a440042 100644 --- a/README.es.md +++ b/README.es.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Otras formas | | diff --git a/README.fr.md b/README.fr.md index 799913a..6e93f21 100644 --- a/README.fr.md +++ b/README.fr.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Autres méthodes | | diff --git a/README.ja.md b/README.ja.md index 7cc2fdc..2db7226 100644 --- a/README.ja.md +++ b/README.ja.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | その他の方法 | | diff --git a/README.md b/README.md index 0fbdfcb..e076c4c 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | Other ways | | @@ -61,7 +61,7 @@ Then click Sleepless in the menu bar, flip the switch, and close the lid. | | | | | --- | ------------------------ | ------------------------------------------------------------------------------------------------ | -| 🤖 | **One switch** | Click the menu-bar agent, flip the toggle. | +| ☕ | **One switch** | Click the menu-bar cup, flip the toggle. | | ⏲️ | **Auto-off timer** | 1h or 2h with a live countdown, then off. | | 🔋 | **Battery floor** | Auto-off at 5–50% on battery (default 15%). | | 🤖 | **Agent-aware auto-off** | Watches local Claude Code, Codex, and Cursor signals, then turns off when no agents are running. | @@ -71,7 +71,7 @@ Then click Sleepless in the menu bar, flip the switch, and close the lid. | 🚀 | **Launch at login** | Optional, off by default, always starts idle. | | 🪶 | **Tiny + native** | Small AppKit app. No Dock icon, daemon, kext, UI scraping, or Screen Recording. | -**Menu-bar glyph:** outline agent = off · filled agent = awake · filled agent + dot = awake on battery (auto-off live). +**Menu-bar glyph:** empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live). ## Sleepless vs the alternatives diff --git a/README.zh-CN.md b/README.zh-CN.md index 3b97147..31e407d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,7 +49,7 @@ ```sh brew install --cask aboudjem/tap/sleepless -/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant +/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant ``` | 其他方式 | |