diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bc384f7..4a42bcb 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -21,7 +21,7 @@ body: attributes: label: Steps to reproduce placeholder: | - 1. Click the coffee cup in the menu bar + 1. Click the Sleepless agent in the menu bar 2. Toggle the switch on 3. Close the lid (on battery) validations: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 95aa0ab..88f1858 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,11 +1,11 @@ name: Feature request -description: Suggest an idea for Sleepless +description: Suggest an idea for Sleepless Agents labels: ["enhancement"] body: - type: markdown attributes: value: | - Sleepless is deliberately small. Features that grow the privilege surface (more + Sleepless Agents is deliberately small. Features that grow the privilege surface (more sudo, a helper daemon, a kext) are unlikely to land — the tight security model is a core feature. Ideas that keep it small, native, and honest are very welcome. - type: textarea diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index beb7ff5..2b3b4ee 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ - + ## What does this change? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 417ccb2..e18e6b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,13 +22,21 @@ 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 \ + AppLogger.swift \ + ShellRunner.swift \ + PowerController.swift \ + AgentMonitor.swift \ + ConnectivityMonitor.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 @@ -42,11 +50,11 @@ jobs: set -euo pipefail # Build the full bundle (conservative target so it works on the runner's SDK). TARGET="arm64-apple-macos13.0" ./build.sh "$PWD/dist" - ls -la "dist/Sleepless.app/Contents" "dist/Sleepless.app/Contents/MacOS" + ls -la "dist/Sleepless Agents.app/Contents" "dist/Sleepless Agents.app/Contents/MacOS" - name: Upload app artifact uses: actions/upload-artifact@v4 with: - name: Sleepless-app - path: dist/Sleepless.app + name: Sleepless-Agents-app + path: dist/Sleepless Agents.app if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 328eb02..c8f8619 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,17 +7,17 @@ name: Release on: push: - tags: ['v*'] + tags: ["v*"] workflow_dispatch: inputs: tag: - description: 'Tag to (re)build a release for, e.g. v1.1.0' + description: "Tag to (re)build a release for, e.g. v1.1.0" required: true permissions: - contents: write # create the release + upload assets - id-token: write # Sigstore OIDC, required by attest-build-provenance - attestations: write # write the build-provenance attestation to this repo + contents: write # create the release + upload assets + id-token: write # Sigstore OIDC, required by attest-build-provenance + attestations: write # write the build-provenance attestation to this repo jobs: release: @@ -40,20 +40,20 @@ jobs: swiftc --version echo "SDK: $(xcrun --sdk macosx --show-sdk-version 2>/dev/null || echo n/a)" - - name: Build Sleepless.app + - name: Build Sleepless Agents.app run: | set -euo pipefail # Conservative deployment target so it compiles against the runner SDK and # runs on macOS 13+. (Local + tested target is arm64-apple-macos26.0.) TARGET="arm64-apple-macos13.0" ./build.sh "$PWD/dist" - codesign --verify --verbose=1 "dist/Sleepless.app" + codesign --verify --verbose=1 "dist/Sleepless Agents.app" - name: Zip the app bundle id: zip run: | set -euo pipefail ASSET="Sleepless-${{ steps.ver.outputs.version }}.zip" - ditto -c -k --keepParent "dist/Sleepless.app" "$ASSET" + ditto -c -k --keepParent "dist/Sleepless Agents.app" "$ASSET" echo "asset=$ASSET" >> "$GITHUB_OUTPUT" ls -la "$ASSET" @@ -98,9 +98,9 @@ jobs: ASSET="${{ steps.zip.outputs.asset }}" if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" "$ASSET" SHA256SUMS --clobber - gh release edit "$TAG" --notes-file NOTES.md --title "Sleepless ${{ steps.ver.outputs.version }}" + gh release edit "$TAG" --notes-file NOTES.md --title "Sleepless Agents ${{ steps.ver.outputs.version }}" else gh release create "$TAG" "$ASSET" SHA256SUMS \ - --title "Sleepless ${{ steps.ver.outputs.version }}" \ + --title "Sleepless Agents ${{ steps.ver.outputs.version }}" \ --notes-file NOTES.md fi diff --git a/AgentMonitor.swift b/AgentMonitor.swift new file mode 100644 index 0000000..d353a7a --- /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 Agents 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 Agents 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 Agents: 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..efb1a94 100644 --- a/App.swift +++ b/App.swift @@ -11,83 +11,140 @@ // 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. +// UI: clicking the menu-bar agent 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. // -// 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. +// The menu-bar mark is a tiny robot whose eyes read at a glance: asleep (gently closed +// eyes) means the Mac sleeps normally, awake (open eyes) means it is being kept awake, +// and an awake robot with a small dot means it is awake on battery with the auto-off +// safety net live. // -// Three small, fail-safe features layer on top, none of which adds a daemon or +// 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 Agents" +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) +// MARK: - Menu-bar robot glyph (hand-drawn MONOCHROME template — state by EXPRESSION) // macOS convention: a menu-bar extra is a template image (no colour) so it adapts to light/dark -// bars and inverts on highlight. State is read from the SILHOUETTE, not colour. The old -// empty-vs-filled cups looked near-identical at 16 px, so we switch the silhouette dramatically -// with steam (a hot cup = awake): -// 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. +// bars and inverts on highlight. We draw a BOLD, FILLED robot silhouette (a solid head that fills +// the bar height, with negative-space eyes + smile + little side ears) so it stays clear and +// legible at menu-bar size — thin outlines read as faint and fuzzy. The FACE communicates state, +// matching the app icon's robot identity: +// OFF (sleeps normally) = robot asleep — gently closed eyes +// ON (kept awake, on power) = robot awake — open round eyes +// ARMED (kept awake, on battery) = awake robot + a small dot (auto-off safety net live) +// Drawn from vectors (not SF Symbols, which have no robot glyph) so it re-renders crisply at the +// menu bar's backing scale. enum SleepGlyph { case off case on case armed } -private func makeCupGlyph(_ glyph: SleepGlyph) -> NSImage { - let cfg = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular).applying(.init(scale: .medium)) - let name = (glyph == .off) ? "cup.and.saucer" : "cup.and.heat.waves.fill" - let base = NSImage(systemSymbolName: name, accessibilityDescription: "Sleepless")? - .withSymbolConfiguration(cfg) - ?? NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: "Sleepless") - ?? NSImage() - - guard glyph == .armed else { - base.isTemplate = true - return base - } - // ARMED: full steaming cup + a small filled dot top-right (the "auto-off safety net is live" - // mark). Drawn in template black so it tints + inverts with the menu bar exactly like the cup. - let size = base.size - guard size.width > 0, size.height > 0 else { base.isTemplate = true; return base } - let composed = NSImage(size: size) - composed.lockFocus() - base.draw(in: NSRect(origin: .zero, size: size)) - let d = max(size.height * 0.26, 4) - let dot = NSBezierPath(ovalIn: NSRect(x: size.width - d, y: size.height - d, width: d, height: d)) - NSColor.black.setFill() - dot.fill() - composed.unlockFocus() - composed.isTemplate = true - return composed +private func makeRobotGlyph(_ glyph: SleepGlyph) -> NSImage { + let asleep = (glyph == .off) + let showDot = (glyph == .armed) + // A bold, filled robot face that nearly fills the canvas height so it reads clearly in the + // bar. State reads from the EYES — open round dots when awake, gently closed slits when asleep. + let W: CGFloat = 19 + let H: CGFloat = 17 + + // A thin downward-curving crescent (used as a negative-space hole for closed eyes / smile). + func crescent(_ cx: CGFloat, _ cy: CGFloat, halfW: CGFloat, thick: CGFloat) -> CGPath { + let p = CGMutablePath() + let l = CGPoint(x: cx - halfW, y: cy) + let r = CGPoint(x: cx + halfW, y: cy) + p.move(to: l) + p.addQuadCurve(to: r, control: CGPoint(x: cx, y: cy - thick)) // bottom edge (lower) + p.addQuadCurve(to: l, control: CGPoint(x: cx, y: cy)) // top edge (back) + p.closeSubpath() + return p + } + + let img = NSImage(size: NSSize(width: W, height: H), flipped: false) { _ in + guard let cg = NSGraphicsContext.current?.cgContext else { return true } + cg.setFillColor(NSColor.black.cgColor) + + // Big, central head (a soft, helmet-like rounded square) that fills the bar height. + let headW: CGFloat = 13.0, headH: CGFloat = 13.0 + let head = CGRect(x: (W - headW) / 2, y: (H - headH) / 2 - 0.2, width: headW, height: headH) + let corner = headW * 0.32 + let midX = head.midX + + // Ears: little rounded nubs on each side at head mid-height (no antennae). Filled so + // they merge into the silhouette. + let earW: CGFloat = 2.0, earH: CGFloat = 5.2 + func earRect(_ sign: CGFloat) -> CGRect { + let ex = sign < 0 ? head.minX - earW * 0.55 : head.maxX - earW * 0.45 + return CGRect(x: ex, y: head.midY - earH / 2, width: earW, height: earH) + } + for sign in [-1.0, 1.0] as [CGFloat] { + cg.addPath(CGPath(roundedRect: earRect(sign), cornerWidth: earW / 2, cornerHeight: earW / 2, transform: nil)) + } + cg.fillPath() + + // Head silhouette with negative-space eyes + smile punched out via even-odd fill. + let p = CGMutablePath() + p.addPath(CGPath(roundedRect: head, cornerWidth: corner, cornerHeight: corner, transform: nil)) + let eyeDX: CGFloat = 2.9 + let eyeY = head.midY + 1.0 + if asleep { + for s in [-eyeDX, eyeDX] { p.addPath(crescent(midX + s, eyeY + 0.3, halfW: 1.9, thick: 1.1)) } + } else { + let r: CGFloat = 1.85 + for s in [-eyeDX, eyeDX] { + p.addEllipse(in: CGRect(x: midX + s - r, y: eyeY - r, width: r * 2, height: r * 2)) + } + } + p.addPath(crescent(midX, head.midY - 2.6, halfW: 2.7, thick: 1.05)) // smile + cg.addPath(p) + cg.fillPath(using: .evenOdd) + + // ARMED: a small filled dot at the top-right corner (auto-off safety net live). + if showDot { + let d: CGFloat = 3.0 + cg.fillEllipse(in: CGRect(x: W - d - 0.2, y: H - d - 0.2, width: d, height: d)) + } + return true + } + img.isTemplate = true + return img } // Flipped container so popover content lays out top-down with simple frames. @@ -139,27 +196,47 @@ private final class CardView: NSView { @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { + private let power = PowerController() + private let agentMonitor = AgentMonitor() + private let connectivityMonitor = ConnectivityMonitor() private var statusItem: NSStatusItem! private var timer: Timer? - private let onGlyph = makeCupGlyph(.on) - private let offGlyph = makeCupGlyph(.off) - private let armedGlyph = makeCupGlyph(.armed) + private let onGlyph = makeRobotGlyph(.on) + private let offGlyph = makeRobotGlyph(.off) + private let armedGlyph = makeRobotGlyph(.armed) // Popover UI 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 robot 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 +244,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 @@ -204,15 +285,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. - let mark = NSImageView(frame: NSRect(x: pad, y: 14, width: 18, height: 18)) - let headerCup = makeCupGlyph(.on); headerCup.isTemplate = true - mark.image = headerCup + // Header: small robot mark + app name. The mark tints to the brand violet while + // the Mac is kept awake (an awake robot face — matching the app icon identity). + let mark = NSImageView(frame: NSRect(x: pad, y: 13, width: 19, height: 18)) + let headerRobot = makeRobotGlyph(.on); headerRobot.isTemplate = true + mark.image = headerRobot 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 +309,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 +321,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 +329,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 +343,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 +471,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return t } - // MARK: - Click the menu-bar cup to open/close the popover + // MARK: - Click the menu-bar robot 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 +496,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 } } @@ -388,44 +533,72 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } // 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 + 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 (and thus the robot + // expression) 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 } @@ -438,7 +611,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 +659,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 +679,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 { @@ -523,7 +849,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { 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 robot + dot). var armed = false if on { let (onBattery, discharging, _) = batteryStatus() @@ -531,30 +857,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 (robot expression) 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.") + : "\(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 = "Stays awake with the lid closed. Turns off " + cutoffs.joined(separator: ", ") + "." + } else { + captionLabel?.stringValue = "Sleeps normally when you close the lid." + } } @objc private func floorSliderChanged(_ sender: NSSlider) { @@ -566,125 +904,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..b77696d --- /dev/null +++ b/AppLogger.swift @@ -0,0 +1,88 @@ +import Foundation + +enum AppLogger { + private static let subsystem = "SleeplessAgents" + private static let maxBytes = 256 * 1024 + private static let queue = DispatchQueue(label: "SleeplessAgents.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..36db283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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. +- Redesigned the app identity around a friendly AI/chatbot robot on a purple plate (a + white "helmet" head with a lavender visor, big eyes, side ears, and straight, vertical + knob-tipped antennae). The menu-bar mark stays a monochrome, template-safe robot — now a + bold, filled face with little ears (no antennae) that fills the bar and reads clearly — + whose state reads from the eyes alone: gently closed when off (the Mac sleeps normally), + open when keeping the Mac awake, and open with a small dot when awake on battery (the + auto-off safety net is live). + ## [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 +51,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 +63,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 +71,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 +79,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 +87,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 +97,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 +108,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 +119,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 +145,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 +160,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 +170,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 +179,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..394d2c7 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,22 @@ +# Context + +## Glossary + +- **Keep-awake state**: The user-controlled state where Sleepless Agents keeps the Mac awake when it would otherwise sleep. +- **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 Agents 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 Agents' monitoring contract. +- **Monitored agent tool**: An installed coding-agent tool for which Sleepless Agents 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 Agents 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 Agents 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 Agents 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 Agents 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 Agents exposes keep-awake controls, safety cutoffs, and monitored agent status. +- **Agent robot logo**: The AI/chatbot robot brand mark (white "helmet" head with a lavender visor, big eyes, straight vertical knob-tipped antennae, and side ears, on a purple plate) used for app and marketing identity. The menu bar uses a simplified monochrome template glyph (a bold, filled robot face with little ears, no antennae) derived from the same idea. +- **Native lightweight app**: Sleepless Agents 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 Agents off. New cutoffs default off until the user enables them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 652b4e1..b4d4696 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,8 +23,8 @@ No Xcode project — just the Command Line Tools: ```sh git clone https://github.com/Aboudjem/Sleepless.git cd Sleepless -./build.sh # builds ./build/Sleepless.app, ad-hoc signed -open build/Sleepless.app +./build.sh # builds ./build/Sleepless Agents.app, ad-hoc signed +open "build/Sleepless Agents.app" ``` `./install.sh` additionally installs the passwordless grant + login item (it prints exactly 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/Info.plist b/Info.plist index 5bf5d26..048b2bb 100644 --- a/Info.plist +++ b/Info.plist @@ -4,8 +4,8 @@ CFBundleExecutable Sleepless CFBundleIdentifier com.aboudjem.Sleepless - CFBundleName Sleepless - CFBundleDisplayName Sleepless + CFBundleName Sleepless Agents + CFBundleDisplayName Sleepless Agents CFBundlePackageType APPL CFBundleIconFile Sleepless CFBundleShortVersionString 1.2.7 diff --git a/PowerController.swift b/PowerController.swift new file mode 100644 index 0000000..accf0e1 --- /dev/null +++ b/PowerController.swift @@ -0,0 +1,57 @@ +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) + } + + @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..fbba3b1 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Sleepless: keep your Mac awake with the lid closed + Sleepless Agents: keep your Mac awake with the lid closed

@@ -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 Agents 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. | - -**Menu-bar glyph:** empty cup = off · full cup = awake · full cup + 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 | +| | | | +| --- | ------------------------ | ------------------------------------------------------------------------------------------------ | +| 🤖 | **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:** outline agent = off · filled agent = awake · filled agent + dot = awake on battery (auto-off live). + +## Sleepless Agents vs the alternatives + +| | **Sleepless Agents** | 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 Agents 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..904b8d9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Sleepless asks for a narrow piece of root privilege, so it owes you a precise account +Sleepless Agents asks for a narrow piece of root privilege, so it owes you a precise account of what that privilege is and why it is safe. This document is that account. Nothing here is hand-waved; every claim is something you can verify on your own machine. @@ -12,9 +12,9 @@ acknowledgement within a few days. Coordinated disclosure is appreciated. Supported version: the latest release on the `main` branch. -## What Sleepless actually does +## What Sleepless Agents actually does -Sleepless keeps a Mac awake with the lid closed by toggling an undocumented but +Sleepless Agents keeps a Mac awake with the lid closed by toggling an undocumented but long-standing `pmset` setting: ``` @@ -29,44 +29,52 @@ sets the kernel's `SleepDisabled` flag, which you can observe yourself: pmset -g | grep SleepDisabled # 1 = on, 0/absent = off ``` -Because it is undocumented, Apple could change or remove it in a future macOS. Sleepless +Because it is undocumented, Apple could change or remove it in a future macOS. Sleepless Agents reads the live value back after every toggle, so the menu-bar state always reflects 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`: +A GUI app has no terminal to type a password into, so Sleepless Agents runs `pmset` through a +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: - `sudo pmset -a sleep 0`, `sudo pmset restoredefaults`, `sudo pmset -a hibernatemode 0`, or any other argument vector **do not match** the rule and will demand a password. The grant cannot be widened by appending flags. -- Sleepless calls `sudo` with an **argv array**, not a shell string +- Sleepless Agents 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 Agents 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. @@ -76,17 +84,34 @@ you can toggle `sudo pmset -a disablesleep 1/0` manually instead and skip the gr ## Reboot resets it (a safety net you can verify) `disablesleep` is a **runtime** setting. A reboot restores normal sleep — there is no way -for Sleepless to leave your Mac permanently unable to sleep. Verify it yourself: toggle on, +for Sleepless Agents to leave your Mac permanently unable to sleep. Verify it yourself: toggle on, reboot, then `pmset -g | grep SleepDisabled` should read `0`. -Sleepless adds a second belt-and-suspenders: a **battery-floor auto-off** (default 15%) +Sleepless Agents adds a second belt-and-suspenders: a **battery-floor auto-off** (default 15%) 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 Agents 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 +Sleepless Agents 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 guarantee: signed, notarized macOS stealers have shipped.) - **Build from source (recommended):** locally compiled apps are **not quarantined**, so @@ -96,9 +121,9 @@ guarantee: signed, notarized macOS stealers have shipped.) Open Anyway**, then confirm. Note: macOS 15 (Sequoia) **removed** the old right-click → Open bypass, so the System Settings path is the supported flow on macOS 15+. -## Why Sleepless can't be on the Mac App Store +## Why Sleepless Agents can't be on the Mac App Store -Some people trust App Store apps more, so it is worth saying plainly: Sleepless can never +Some people trust App Store apps more, so it is worth saying plainly: Sleepless Agents can never ship there, and that is a property of what it does, not an oversight. App Review **§2.4.5(v)** states apps "may not request escalation to root privileges or use @@ -110,7 +135,7 @@ outside their container, which the `/etc/sudoers.d` drop-in does). A privileged- workaround does not rescue it either: a helper installed from a sandboxed app must itself be sandboxed, so it still cannot write `/etc/sudoers.d` or run arbitrary root commands. -The practical consequence: Sleepless is **direct-download / Homebrew only**, by design. The +The practical consequence: Sleepless Agents is **direct-download / Homebrew only**, by design. The verification steps below, plus building from source, are how trust is established instead. ## Verifying a download 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/assets/Sleepless-1024.png b/assets/Sleepless-1024.png index b284739..25d631e 100644 Binary files a/assets/Sleepless-1024.png and b/assets/Sleepless-1024.png differ diff --git a/assets/Sleepless.icns b/assets/Sleepless.icns index 181a9d7..cc429f3 100644 Binary files a/assets/Sleepless.icns and b/assets/Sleepless.icns differ diff --git a/assets/social-preview.png b/assets/social-preview.png index 73ff412..e865648 100644 Binary files a/assets/social-preview.png and b/assets/social-preview.png differ diff --git a/assets/social-preview.svg b/assets/social-preview.svg index 415c9e5..f2e5a95 100644 --- a/assets/social-preview.svg +++ b/assets/social-preview.svg @@ -9,7 +9,7 @@ - + Sleepless Keep your Mac awake. Lid closed. On battery. No external display. Native menu-bar app. diff --git a/build.sh b/build.sh index 156d041..427ecb0 100755 --- a/build.sh +++ b/build.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# build.sh — compile Sleepless.app from source with the Command Line Tools only. +# build.sh — compile Sleepless Agents.app from source with the Command Line Tools only. # # No Xcode project, no Package.swift: just `swiftc` + a hand-assembled .app bundle, # ad-hoc signed. Works from any clone (no hardcoded paths or usernames). # # Usage: -# ./build.sh # build into ./build/Sleepless.app +# ./build.sh # build into ./build/Sleepless Agents.app # ./build.sh /Applications # build straight into /Applications # DEST=/Applications ./build.sh # same, via env # ./build.sh --regen-icon # re-render the .icns from make-icon.swift first @@ -15,7 +15,8 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_NAME="Sleepless" +APP_NAME="Sleepless Agents" +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,49 @@ 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 \ + "$REPO/AppLogger.swift" \ + "$REPO/ShellRunner.swift" \ + "$REPO/PowerController.swift" \ + "$REPO/AgentMonitor.swift" \ + "$REPO/ConnectivityMonitor.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..e3b0e19 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 Agents.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 Agents.app" -ditto -c -k --keepParent Sleepless.app Sleepless.zip +ditto -c -k --keepParent "Sleepless Agents.app" Sleepless.zip xcrun notarytool submit Sleepless.zip --keychain-profile "notarytool-password" --wait -xcrun stapler staple Sleepless.app +xcrun stapler staple "Sleepless Agents.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..bb3c2de 100644 --- a/docs/LAUNCH.md +++ b/docs/LAUNCH.md @@ -52,7 +52,7 @@ fresh launch window. - **Qualifies:** you built it, anyone can build/run/inspect it (open source). Good fit. - **Title (no caps, no superlatives, no exclamation):** - `Show HN: Sleepless – keep a Mac awake with the lid closed` + `Show HN: Sleepless Agents – keep a Mac awake with the lid closed` - **Text:** @@ -60,7 +60,7 @@ fresh launch window. > lid shut (overnight builds, long downloads, an agent run, sharing a hotspot from my bag) > and kept forgetting to turn it back off, which drains the battery and traps heat. > - > Sleepless is a tiny AppKit menu-bar app that does exactly that one thing: flips + > Sleepless Agents is a tiny AppKit menu-bar app that does exactly that one thing: flips > `disablesleep` from a native switch, on battery, with no external display, and adds a > battery-floor auto-off (default 15%) so a forgotten "on" state can't cook the battery. A > reboot also resets it. @@ -86,7 +86,7 @@ fresh launch window. - **Before posting:** read the sidebar + `reddit.com/r/macapps/wiki`, check for a required post flair (e.g. a developer/promo flair) and any karma/age gate, and look for a pinned promo/megathread. Be an active member first. -- **Title:** `Sleepless – open-source menu-bar app to keep your Mac awake with the lid closed (on battery, no external display)` +- **Title:** `Sleepless Agents – open-source menu-bar app to keep your Mac awake with the lid closed (on battery, no external display)` - **Body:** > Open source (MIT). A small menu-bar app that keeps a MacBook awake with the lid closed, @@ -112,7 +112,7 @@ fresh launch window. Sundays** ("Self-promotion Sunday"), as a **self-post** (no bare links). You must have **≥5 unrelated posts/comments in r/apple in the past month**, and self-promo must be **≤10%** of your activity. Abuse = instant ban. Build that history organically first. -- **Title:** `[Self-promotion Sunday] Sleepless – keep your Mac awake with the lid closed, on battery (open source)` +- **Title:** `[Self-promotion Sunday] Sleepless Agents – keep your Mac awake with the lid closed, on battery (open source)` - **Body:** reuse the r/macapps body above, plus one line on the security model (passwordless sudoers limited to two exact `pmset disablesleep` commands; reboot + battery-floor reset). @@ -123,14 +123,14 @@ fresh launch window. - **When:** schedule for **12:01am Pacific**; for a dev tool, a weekend (esp. Sunday) is a known lower-competition slot. Prime ~300–400 warm people beforehand; PH amplifies momentum, it does not create it. "Launched" is not "Featured" (PH curates the homepage). -- **Name:** Sleepless +- **Name:** Sleepless Agents - **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`). + agent-robot 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:** - > Hi PH. I built Sleepless because I kept dropping to `sudo pmset disablesleep 1` to keep my + > Hi PH. I built Sleepless Agents because I kept dropping to `sudo pmset disablesleep 1` to keep my > MacBook running with the lid shut (overnight builds, hotspot in my bag) and kept forgetting > to turn it off. It is a tiny, open-source menu-bar switch that does just that, on battery, > with no external display, plus a battery-floor auto-off so it is safe to forget. Native, @@ -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..87176e3 100644 --- a/docs/LISTINGS.md +++ b/docs/LISTINGS.md @@ -1,6 +1,6 @@ # Listings & directories -Where Sleepless can be listed, what is automatable, and ready-to-fire drafts for the +Where Sleepless Agents can be listed, what is automatable, and ready-to-fire drafts for the places that need your own account. The narrative launch posts (Show HN, Reddit, Product Hunt, AlternativeTo, MacUpdate) live in [LAUNCH.md](LAUNCH.md); this file is the directory and awesome-list map. @@ -35,7 +35,7 @@ category `"menubar"` (lowercase). Descriptions end with a period. One PR. "short_description": "Keep your MacBook awake with the lid closed, on battery, with no external display, with a battery-floor auto-off.", "categories": ["menubar"], "repo_url": "https://github.com/Aboudjem/Sleepless", - "title": "Sleepless", + "title": "Sleepless Agents", "languages": ["swift"] } ``` @@ -47,7 +47,7 @@ Edit `README.md` under `### System Related Tools`, alphabetical, insert just abo Mirror into `README-zh.md` / `README-ja.md` / `README-ko.md` (maintainer prefers all locales synced). Description ends with a period. ```markdown -* [Sleepless](https://github.com/Aboudjem/Sleepless) - Keep your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon] +* [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Keep your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon] ``` `[OSS Icon]` and `[Freeware Icon]` reference labels are already defined in the file. @@ -57,7 +57,7 @@ between `ShiftIt` and `SlowQuitApps`. Title case, description ends with a period template fully (the maintainer closes non-compliant PRs). Native AppKit passes the no-Electron rule. ```markdown -- [Sleepless](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon] +- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon] ``` ### 4. jaywcjlove/awesome-swift-macos-apps @@ -66,7 +66,7 @@ auto star + last-commit badges, no text badges. This list already has near-ident (Aquarium, StayAwake, StayUp), so **lead with the battery-floor auto-off differentiator** to avoid a redundancy flag. ```markdown -- [Sleepless](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. +- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. ``` ## C. Mac-Menubar-Megalist (issue, not PR) @@ -74,20 +74,20 @@ avoid a redundancy flag. Open an issue at https://github.com/SKaplanOfficial/Mac-Menubar-Megalist/issues/new. The keep-awake cluster is `## Utilities → #### Caffeinators`. **Disambiguate from two existing look-alikes**: "Sleepless Mac" (github.com/gsurma/sleepless_mac) and "Sleep Blocker (Sleepless -Mode)" (App Store). No entry named exactly "Sleepless" exists yet. +Mode)" (App Store). No entry named exactly "Sleepless Agents" exists yet. -- **Title:** `Add app: Sleepless` +- **Title:** `Add app: Sleepless Agents` - **Body:** - > **App Name:** Sleepless + > **App Name:** Sleepless Agents > **URL:** https://github.com/Aboudjem/Sleepless > **Website:** https://aboudjem.github.io/Sleepless/ > **Category:** Utilities → Caffeinators > **License:** MIT (free, open source) > > Paste-ready list line: - > `- [Sleepless](https://github.com/Aboudjem/Sleepless) by [Adam Boudjemaa](https://github.com/Aboudjem). Keeps your MacBook awake with the lid closed on battery, with no external display and a battery-floor auto-off. Free, open source.` + > `- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) by [Adam Boudjemaa](https://github.com/Aboudjem). Keeps your MacBook awake with the lid closed on battery, with no external display and a battery-floor auto-off. Free, open source.` > - > Note: this is "Sleepless" by Aboudjem, distinct from the existing "Sleepless Mac" + > Note: this is "Sleepless Agents" by Aboudjem, distinct from the existing "Sleepless Mac" > (gsurma) and "Sleep Blocker (Sleepless Mode)" entries already in the Caffeinators list. ## D. Human-account drafts (fire these yourself; do not automate) @@ -95,7 +95,7 @@ Mode)" (App Store). No entry named exactly "Sleepless" exists yet. ### MacMenuBar.com Submit at https://macmenubar.com/submit-your-menu-bar-app/ (menu-bar-only directory; check it is not already listed first). -> **Name:** Sleepless +> **Name:** Sleepless Agents > **Category:** Utilities / Menu Bar > **Description:** Open-source macOS menu-bar app that keeps your MacBook awake with the lid > closed, on battery, with no external display, using `pmset disablesleep`. Adds an auto-off @@ -112,7 +112,7 @@ https://mac.softpedia.com/ (editor-reviewed). The "100% Clean" badge is a nice R > Category: Utilities. Description as above. Note macOS 26 / Apple Silicon requirement. ### opensourcealternative.to -https://www.opensourcealternative.to/submit (OSS-only; Sleepless qualifies). +https://www.opensourcealternative.to/submit (OSS-only; Sleepless Agents qualifies). > Position as an open-source alternative to Amphetamine and KeepingYouAwake. Tags: macOS, > menu-bar, keep-awake, productivity, open-source. @@ -122,9 +122,9 @@ spending time. ### r/SideProject Self-post; promotion is welcomed here. Lead with the demo GIF. -> **Title:** Sleepless – a tiny open-source Mac menu-bar app that keeps your MacBook awake with the lid closed (on battery, no external display) +> **Title:** Sleepless Agents – a tiny open-source Mac menu-bar app that keeps your MacBook awake with the lid closed (on battery, no external display) > **Body:** I kept typing `sudo pmset -a disablesleep 1` to keep my MacBook running with the -> lid shut for overnight jobs, then forgetting to turn it back off. Sleepless is that command +> lid shut for overnight jobs, then forgetting to turn it back off. Sleepless Agents is that command > as a one-click menu-bar switch, with an auto-off timer and a battery-floor cutoff so it can't > drain the battery. Native AppKit, no daemon, no kext, MIT. Build from source or `brew install > --cask aboudjem/tap/sleepless`. Feedback welcome. https://github.com/Aboudjem/Sleepless @@ -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, @@ -153,7 +153,7 @@ Pitch the lid-closed-on-battery angle + the security model, with the demo GIF. ## E. Blocked / cannot submit - **Homebrew core cask**: self-submission needs 90 forks / 90 watchers / 225★ (or 75★ if an - unaffiliated person submits). Sleepless is far under, so stay on `aboudjem/tap`. Revisit after + unaffiliated person submits). Sleepless Agents is far under, so stay on `aboudjem/tap`. Revisit after 225★. https://docs.brew.sh/Acceptable-Casks - **Changelog Nightly**: algorithmic from GitHub Archive; can't submit, only earned via a star-velocity spike. Coordinate with a launch. diff --git a/docs/adr/0001-local-agent-detection.md b/docs/adr/0001-local-agent-detection.md new file mode 100644 index 0000000..945d3f2 --- /dev/null +++ b/docs/adr/0001-local-agent-detection.md @@ -0,0 +1,3 @@ +# Local Agent Detection + +Sleepless Agents 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 Agents 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/docs/icon.png b/docs/icon.png index 08d1856..449c134 100644 Binary files a/docs/icon.png and b/docs/icon.png differ diff --git a/docs/index.html b/docs/index.html index 35f7aff..5980a75 100644 --- a/docs/index.html +++ b/docs/index.html @@ -1,35 +1,41 @@ + - - -Sleepless: keep your Mac awake with the lid closed - - - - - - - - - - - - - - - - - - - - - - - - - + + + - - - - - -
-
-
- Sleepless app icon, a coffee cup -

Keep your Mac awake.
Lid closed.

-

Sleepless keeps your MacBook awake with the lid closed, on battery, with no external display. One native menu-bar switch, with an auto-off timer and a battery-floor cutoff so you never drain it flat.

- -
- $ brew install --cask aboudjem/tap/sleepless - -
-
- CI - Release - Build provenance: attested - License: MIT - Stars -
-

macOS 26 · Apple Silicon · MIT · no Dock icon, no daemon, no kext

-
-
-
- -
- -
-
-
-
- -

Trust it, then forget it

+ + +
+
+
+ Sleepless app icon, a friendly AI robot +

Keep your Mac awake.
Lid closed.

+

Sleepless Agents keeps your MacBook awake with the lid closed, on battery, with no external + display. + One native menu-bar switch, with an auto-off timer and a battery-floor cutoff so you never drain it flat.

+ +
+ $ brew install --cask aboudjem/tap/sleepless + +
+
+ CI + Release + Build provenance: attested + License: MIT + Stars +
+

macOS 26 · Apple Silicon · MIT · no Dock icon, no daemon, no kext

-

It holds one scoped sudo grant and runs your Mac unattended, so every release is built in the open and signed so you can prove what you ran.

-
-
Build provenanceSLSA attestation signed via Sigstore in CI. Verify with gh attestation verify.
-
SHA-256 checksumsEvery release ships SHA256SUMS. No Apple account needed to check.
-
No telemetryNo analytics, no network calls. It only flips one local pmset flag.
-
Open source, MITOne AppKit file. No daemon, no kernel extension. Read every line.
+
+
+
+ The Sleepless menu-bar popover: a switch, an auto-off timer, a battery-floor slider, and a launch-at-login toggle +
-
-
verify a download
-
shasum -a 256 -c SHA256SUMS
+    
+
+ +
+
+
+
+ + + + + +

Trust it, then forget it

+
+

It holds one scoped sudo grant and runs your Mac unattended, so every release + is built in the open and signed so you can prove what you ran.

+
+
+ + + +
Build provenanceSLSA attestation signed via Sigstore in CI. Verify with + gh attestation verify.
+
+
+ + + + +
SHA-256 checksumsEvery release ships SHA256SUMS. No Apple account needed to + check.
+
+
+ + + + +
No telemetryNo analytics. The optional internet cutoff uses a tiny reachability probe only + when enabled.
+
+
+ + + +
Open source, MITSmall native AppKit app. No daemon, no kernel extension, no UI + scraping.
+
+
+
+
verify a download +
+
shasum -a 256 -c SHA256SUMS
 gh attestation verify Sleepless-1.2.0.zip -R Aboudjem/Sleepless
+
-
-
- -
-
-

The one that actually does it

-

Why other keep-awake apps can't do this

-

Most keep-awake apps ride on macOS power assertions. Assertions stop the idle timer, but they can't override the hardware lid-close trigger, so a closed lid still sleeps the Mac. Sleepless flips the one setting that can.

-
-
01

Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close trigger.

-
02

So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.

-
03

pmset disablesleep overrides lid-close sleep. Sleepless flips exactly that, reads it back, and adds safety nets.

-
-
“I will probably never support this option because caffeinate doesn't support this. KYA uses caffeinate under the hood.”KeepingYouAwake maintainer, issue #66
-
-
- -
-
-

Small on purpose

-

Everything it does, nothing it doesn't

-
-
-
-

One switch

Click the coffee cup, flip the toggle. Empty cup is off, full cup is awake, full cup with a dot is awake on battery. The menu-bar glyph never lies about the real state.

-
-
-
-

Auto-off timer

Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.

-
-
-
-

Battery-floor cutoff

Pick a floor from 5 to 50% (default 15%). On battery, it turns itself off before you run flat.

+
+ +
+
+

The one that actually does it

+

Why other keep-awake apps can't do this

+

Most keep-awake apps ride on macOS power assertions. Assertions stop the idle timer, but they + can't override the hardware lid-close trigger, so a closed lid still sleeps the Mac. Sleepless flips the one + setting that can.

+
+
01 +

Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close + trigger.

+
+
02 +

So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.

+
+
03 +

pmset disablesleep overrides lid-close sleep. Sleepless flips exactly that, reads it back, and + adds safety nets.

+
-
-
-

Low Power Mode aware

On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.

+
“I will probably never support this option because caffeinate doesn't + support this. KYA uses caffeinate under the hood.”KeepingYouAwake maintainer, issue + #66
+
+
+ +
+
+

Small on purpose

+

Everything it does, nothing it doesn't

+
+
+
+ + +
+

One switch

+

Click the menu-bar agent, flip the toggle. Outline is off, filled is awake, filled with a dot is awake on + battery. The menu-bar glyph never lies about the real state.

+
+
+
+ + + +
+

Auto-off timer

+

Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.

+
+
+
+ + + +
+

Battery-floor cutoff

+

Pick a floor from 5 to 50% (default 15%). On battery, it turns itself off before you run flat.

+
+
+
+ + +
+

Low Power Mode aware

+

On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.

+
+
+
+ + + +
+

No display, no dongle

+

Just the lid closed, on battery. No external monitor, no dummy HDMI plug.

+
+
+
+ +
+

Tiny and native

+

Small AppKit codebase with SF Symbols. No Dock icon, no background daemon, no kext, no UI scraping.

+
-
-
-

No display, no dongle

Just the lid closed, on battery. No external monitor, no dummy HDMI plug.

+

Menu-bar glyph: outline agent = off · filled agent = awake · filled agent + dot = + awake on + battery (auto-off live).

+
+
+ +
+
+

How it works

+

One setting, flipped safely

+

Sleepless toggles pmset disablesleep, which flips the kernel's + SleepDisabled flag, then reverts it at your battery floor, in Low Power Mode, or when the timer + ends. A reboot also resets it. Because a GUI app can't type a password, the installer adds a tightly scoped + sudoers rule that permits exactly two commands and nothing else. +

+
+
/etc/sudoers.d/sleepless
+
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
-
-
-

Tiny and native

One AppKit file with SF Symbols. No Dock icon, no background daemon, no kext, no dependencies.

+
+
+
+ + +
+

Can't be widened

+

sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.

+
+
+
+ + +
+

No privileged helper to hijack

+

The keep-awake toggle calls Apple's /usr/bin/pmset directly with an argv array. No daemon and no privileged + helper script.

+
+
+
+ + +
+

Always reversible

+

A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves + it.

+
+
+
+ + +
+

Verifiable downloads

+

Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check. +

+
+

Full threat model and verification: SECURITY.md and AUDIT.md.

-

Menu-bar glyph: empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live).

-
-
- -
-
-

How it works

-

One setting, flipped safely

-

Sleepless toggles pmset disablesleep, which flips the kernel's SleepDisabled flag, then reverts it at your battery floor, in Low Power Mode, or when the timer ends. A reboot also resets it. Because a GUI app can't type a password, the installer adds a tightly scoped sudoers rule that permits exactly two commands and nothing else.

-
-
/etc/sudoers.d/sleepless
-
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
-
-
-

Can't be widened

sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.

-

No helper to hijack

It calls Apple's /usr/bin/pmset directly with an argv array. No daemon, no shell, no helper script.

-

Always reversible

A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves it.

-

Verifiable downloads

Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check.

+
+ +
+
+

As of 2026-06

+

Sleepless vs the alternatives

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Sleepless AgentsAmphetamineKeepingYouAwakecaffeinate
Awake, lid closed, no monitorYes ¹Flaky ²No ³No
On batteryYesYesLid openPartial ⁴
Auto-off timerYesYesYesNo
Auto-off on low batteryYesYesYesNo
Open sourceMITApp StoreMITApple
CostFreeFreeFreeFree
+
+

¹ Sleepless uses pmset disablesleep, the mechanism built for lid-close, and reads the + flag back so the UI reflects reality; behavior on any keep-awake tool is hardware and macOS-version dependent. ² + Amphetamine documents closed-display mode but is widely reported to fail on Apple Silicon when the power source + changes (Amphetamine-Enhancer #28); the app itself is closed source. ³ KeepingYouAwake cannot keep the lid + closed by design, since it wraps caffeinate (issue #66). ⁴ caffeinate -i runs on + battery; -s is AC-only.

-

Full threat model and verification: SECURITY.md and AUDIT.md.

- -
- -
-
-

As of 2026-06

-

Sleepless vs the alternatives

-
- - - - - - - - - - -
SleeplessAmphetamineKeepingYouAwakecaffeinate
Awake, lid closed, no monitorYes ¹Flaky ²No ³No
On batteryYesYesLid openPartial ⁴
Auto-off timerYesYesYesNo
Auto-off on low batteryYesYesYesNo
Open sourceMITApp StoreMITApple
CostFreeFreeFreeFree
+
+ +
+
+

Questions

+

FAQ

+
+ How do I keep my MacBook awake with the lid closed without a monitor? +

Install Sleepless, click the agent glyph in the menu bar, flip the switch on, and close the lid (the laptop + screen, also called the flap). It keeps the Mac awake on battery with no external display, using + pmset disablesleep. No dummy HDMI plug or clamshell adapter is needed. +

+
+
+ Why does my MacBook sleep when I close the lid even with Amphetamine or KeepingYouAwake? +

Those tools are built on macOS power assertions, which stop the idle timer but cannot override the hardware + lid-close trigger. KeepingYouAwake wraps caffeinate, which its maintainer confirms cannot do + lid-close. pmset disablesleep, which Sleepless uses, is a lower-level setting that can.

+
+
+ 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 a MacBook awake with the lid closed on battery. Apple does not + officially document the setting, so verify it with pmset -g | grep SleepDisabled. Most claims + that it stopped working describe caffeinate, a different mechanism.

+
+
+ Is it safe to run a MacBook with the lid closed? Will it overheat or drain the battery? +

It is safe for light, unattended work like downloads, syncs, or a hotspot. Heavy sustained load with the lid + fully closed reduces airflow, so use judgement there. Sleepless turns itself off at the floor you set and in + Low Power Mode, and the auto-off timer caps how long it stays on.

+
+
+ Does Sleepless require sudo, a kernel extension, or a background daemon? +

It needs one tightly scoped sudo grant (two exact pmset commands, nothing else) so + a GUI app can flip the setting without a password prompt. There is no kernel extension and no background + daemon, and no privileged helper.

+
+
+ Can I run AI agents or long jobs overnight with the lid closed? +

Yes. Switch Sleepless on, set a battery floor, close the lid, and an agent run, build, render, or training + job keeps going. Plug in for an all-nighter, or stay on battery with a floor and timer so it stops itself + before the battery runs low.

+
-

¹ Sleepless uses pmset disablesleep, the mechanism built for lid-close, and reads the flag back so the UI reflects reality; behavior on any keep-awake tool is hardware and macOS-version dependent. ² Amphetamine documents closed-display mode but is widely reported to fail on Apple Silicon when the power source changes (Amphetamine-Enhancer #28); the app itself is closed source. ³ KeepingYouAwake cannot keep the lid closed by design, since it wraps caffeinate (issue #66). ⁴ caffeinate -i runs on battery; -s is AC-only.

- -
- -
-
-

Questions

-

FAQ

-
How do I keep my MacBook awake with the lid closed without a monitor?

Install Sleepless, click the coffee cup in the menu bar, flip the switch on, and close the lid (the laptop screen, also called the flap). It keeps the Mac awake on battery with no external display, using pmset disablesleep. No dummy HDMI plug or clamshell adapter is needed.

-
Why does my MacBook sleep when I close the lid even with Amphetamine or KeepingYouAwake?

Those tools are built on macOS power assertions, which stop the idle timer but cannot override the hardware lid-close trigger. KeepingYouAwake wraps caffeinate, which its maintainer confirms cannot do lid-close. pmset disablesleep, which Sleepless uses, is a lower-level setting that can.

-
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 a MacBook awake with the lid closed on battery. Apple does not officially document the setting, so verify it with pmset -g | grep SleepDisabled. Most claims that it stopped working describe caffeinate, a different mechanism.

-
Is it safe to run a MacBook with the lid closed? Will it overheat or drain the battery?

It is safe for light, unattended work like downloads, syncs, or a hotspot. Heavy sustained load with the lid fully closed reduces airflow, so use judgement there. Sleepless turns itself off at the floor you set and in Low Power Mode, and the auto-off timer caps how long it stays on.

-
Does Sleepless require sudo, a kernel extension, or a background daemon?

It needs one tightly scoped sudo grant (two exact pmset commands, nothing else) so a GUI app can flip the setting without a password prompt. There is no kernel extension and no background daemon. The whole app is a single AppKit file.

-
Can I run AI agents or long jobs overnight with the lid closed?

Yes. Switch Sleepless on, set a battery floor, close the lid, and an agent run, build, render, or training job keeps going. Plug in for an all-nighter, or stay on battery with a floor and timer so it stops itself before the battery runs low.

-
-
- -
-
-
- Sleepless app icon -

Keep it awake in two minutes

-

Install with Homebrew, or build it from source and read every line first.

-
-
$ brew install --cask aboudjem/tap/sleepless
- Download for macOS +
+ +
+
+
+ Sleepless app icon +

Keep it awake in two minutes

+

Install with Homebrew, or build it from source and read every line first.

+
+
$ brew install --cask + aboudjem/tap/sleepless
+ + + Download for macOS +
- -
- - - - - + + + + - + + \ No newline at end of file diff --git a/docs/og.png b/docs/og.png index 73ff412..e865648 100644 Binary files a/docs/og.png and b/docs/og.png differ diff --git a/grant.sh b/grant.sh index 39559b1..729f604 100755 --- a/grant.sh +++ b/grant.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# grant.sh — install ONLY the passwordless grant that lets Sleepless toggle lid-close +# grant.sh — install ONLY the passwordless grant that lets Sleepless Agents toggle lid-close # sleep without a prompt. Self-contained: works from a clone OR from inside the app # bundle (Contents/Resources), so Homebrew-cask users can run it after install. # @@ -9,34 +9,39 @@ 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):" +echo "Sleepless Agents will install this passwordless grant at $SUDOERS_DST (root:wheel, 0440):" echo "" echo " $GRANT" echo "" @@ -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)." -echo " Toggle Sleepless from the menu bar; it will no longer need a password." +"${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 Agents from the menu bar; it will no longer need a password." diff --git a/install.sh b/install.sh index 3b186c8..3c26213 100755 --- a/install.sh +++ b/install.sh @@ -7,12 +7,16 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -APP_NAME="Sleepless" +APP_NAME="Sleepless Agents" 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 Agents 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/make-icon.swift b/make-icon.swift index 4e20218..94259f6 100644 --- a/make-icon.swift +++ b/make-icon.swift @@ -1,13 +1,11 @@ // Sleepless app icon generator (native, AppKit-rendered). // -// Renders the SAME coffee cup the menu bar uses -- Apple's `cup.and.saucer.fill` -// SF Symbol, drawn in white on a continuous-curvature ("squircle") Liquid-Glass -// plate carrying the brand's indigo -> violet -> fuchsia gradient -- so the -// Dock/Finder icon is brand-consistent with the native menu-bar glyph and reads -// premium, not hand-rolled. The full white cup is the "caffeinated / kept awake" -// mark; a soft aurora steam wisp rises from it (the brand signature) at larger -// sizes only, so the small Dock/menu sizes stay clean and legible. Each iconset -// size is rendered directly from the vector symbol (no raster downscaling). +// Renders a friendly AI/chatbot robot — white "helmet" head with a lavender visor, +// two big eyes, knob-tipped antennae and side ears — on a continuous-curvature +// ("squircle") Liquid-Glass plate carrying the brand's purple gradient. The +// Dock/Finder icon carries the full robot identity; the menu bar still uses a +// simplified monochrome template glyph so it stays legible at 16 px. +// Each iconset size is rendered directly from vector shapes (no raster downscaling). // // Build + run: swiftc -O -framework AppKit make-icon.swift -o /tmp/mkicon && /tmp/mkicon [outDir] // Then: iconutil -c icns Sleepless.iconset -o Sleepless.icns @@ -16,12 +14,12 @@ // (No hardcoded paths, so it works from any clone — build.sh passes a temp dir.) import AppKit -// ---- Brand palette (2026 redesign): indigo -> violet -> fuchsia diagonal gradient, +// ---- Brand palette (2026 redesign): a violet -> deep-purple diagonal gradient, // lighter at the top-left so it harmonises with the system's icon lighting. The -// white cup reads cleanly on top; the violet mid-stop matches the popover accent. -let plateTop = NSColor(srgbRed: 124/255.0, green: 140/255.0, blue: 255/255.0, alpha: 1) // #7C8CFF light indigo +// white robot reads cleanly on top; the violet mid-stop matches the popover accent. +let plateTop = NSColor(srgbRed: 167/255.0, green: 139/255.0, blue: 250/255.0, alpha: 1) // #A78BFA light violet let plateMid = NSColor(srgbRed: 139/255.0, green: 92/255.0, blue: 246/255.0, alpha: 1) // #8B5CF6 violet -let plateBot = NSColor(srgbRed: 192/255.0, green: 38/255.0, blue: 211/255.0, alpha: 1) // #C026D3 fuchsia/magenta +let plateBot = NSColor(srgbRed: 109/255.0, green: 40/255.0, blue: 217/255.0, alpha: 1) // #6D28D9 deep purple let outDir = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] @@ -59,8 +57,8 @@ func renderIcon(_ S: CGFloat) -> NSBitmapImageRep { let plate = CGRect(x: gutter, y: gutter, width: S - 2 * gutter, height: S - 2 * gutter) let path = squirclePath(rect: plate) - // Plate fill: indigo -> violet -> fuchsia diagonal gradient (lighter top-left, - // deeper bottom-right) so it agrees with the system icon lighting. + // Plate fill: light-violet -> violet -> deep-purple diagonal gradient (lighter + // top-left, deeper bottom-right) so it agrees with the system icon lighting. cg.saveGState() cg.addPath(path); cg.clip() let cs = CGColorSpaceCreateDeviceRGB() @@ -68,8 +66,8 @@ func renderIcon(_ S: CGFloat) -> NSBitmapImageRep { colors: [plateTop.cgColor, plateMid.cgColor, plateBot.cgColor] as CFArray, locations: [0, 0.5, 1])! cg.drawLinearGradient(grad, - start: CGPoint(x: plate.minX, y: plate.maxY), // top-left (light indigo) - end: CGPoint(x: plate.maxX, y: plate.minY), // bottom-right (deep fuchsia) + start: CGPoint(x: plate.minX, y: plate.maxY), // top-left (light violet) + end: CGPoint(x: plate.maxX, y: plate.minY), // bottom-right (deep purple) options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) // Soft top-left glass sheen (specular highlight, radial white), clipped to plate. @@ -80,7 +78,7 @@ func renderIcon(_ S: CGFloat) -> NSBitmapImageRep { cg.drawRadialGradient(sheen, startCenter: gc, startRadius: 0, endCenter: gc, endRadius: plate.width * 0.66, options: []) - // Faint white "lit from within" glow behind the cup for depth. + // Faint white "lit from within" glow behind the robot for depth. let glow = CGGradient(colorsSpace: cs, colors: [NSColor(white: 1, alpha: 0.12).cgColor, NSColor(white: 1, alpha: 0).cgColor] as CFArray, locations: [0, 1])! @@ -88,81 +86,92 @@ func renderIcon(_ S: CGFloat) -> NSBitmapImageRep { cg.drawRadialGradient(glow, startCenter: glowC, startRadius: 0, endCenter: glowC, endRadius: plate.width * 0.44, options: []) - // Aurora steam signature: soft violet -> fuchsia wisps that CURL sideways and - // dissipate at rounded tips, so the mark reads as steam (never a flame) while still - // carrying the brand gradient. Two staggered curls at large sizes; one bolder curl - // at small sizes so it survives in the Dock. The white cup always leads. - if S >= 24 { - let single = S <= 64 - // each wisp: (baseDx, lateral drift toward tip, height fraction, base width) - // Both wisps lean the same way (a soft draft) with different heights/widths/curl, - // so they read as drifting steam, not symmetric "ears". - let wisps: [(dx: CGFloat, drift: CGFloat, hf: CGFloat, w: CGFloat)] = single - ? [(-0.01, 0.13, 1.0, 0.10)] - : [(-0.045, 0.05, 1.0, 0.072), (0.075, 0.16, 0.78, 0.058)] - let baseY = plate.midY + plate.height * 0.10 // at the cup rim - let fullTop = plate.maxY - plate.height * 0.06 - // faint vapor halo (well below the flame-like glow of the prior pass) - let halo = CGGradient(colorsSpace: cs, - colors: [NSColor(srgbRed: 0.93, green: 0.52, blue: 1.0, alpha: 0.20).cgColor, - NSColor(srgbRed: 0.93, green: 0.52, blue: 1.0, alpha: 0).cgColor] as CFArray, - locations: [0, 1])! - let haloC = CGPoint(x: plate.midX, y: (baseY + fullTop) / 2) - cg.drawRadialGradient(halo, startCenter: haloC, startRadius: 0, - endCenter: haloC, endRadius: plate.width * 0.16, options: []) - for wp in wisps { - let topY = baseY + (fullTop - baseY) * wp.hf - let x0 = plate.midX + plate.width * wp.dx - let steps = 30 - func pt(_ u: CGFloat, _ side: CGFloat) -> CGPoint { - let y = baseY + (topY - baseY) * u - let curl = plate.width * wp.drift * (u * u) // drift grows toward the tip = curl - let wave = plate.width * 0.028 * sin(u * .pi * 2.1) // gentle squiggle - let half = (plate.width * wp.w) * pow(1 - u, 0.55) * 0.5 + plate.width * 0.004 // taper to a rounded tip - return CGPoint(x: x0 + curl + wave + side * half, y: y) - } - let ribbon = CGMutablePath() - ribbon.move(to: pt(0, -1)) - for i in 1...steps { ribbon.addLine(to: pt(CGFloat(i)/CGFloat(steps), -1)) } - for i in stride(from: steps, through: 0, by: -1) { ribbon.addLine(to: pt(CGFloat(i)/CGFloat(steps), 1)) } - ribbon.closeSubpath() - cg.saveGState(); cg.addPath(ribbon); cg.clip() - let steam = CGGradient(colorsSpace: cs, - colors: [NSColor(srgbRed: 0.80, green: 0.70, blue: 1.0, alpha: 0.92).cgColor, // bright violet - NSColor(srgbRed: 0.95, green: 0.60, blue: 1.0, alpha: 0.80).cgColor, // fuchsia - NSColor(srgbRed: 1.0, green: 0.78, blue: 1.0, alpha: 0.0).cgColor] as CFArray, // dissipate - locations: [0, 0.55, 1])! - cg.drawLinearGradient(steam, start: CGPoint(x: x0, y: baseY), - end: CGPoint(x: x0 + plate.width * wp.drift, y: topY), options: []) - cg.restoreGState() - } + cg.restoreGState() + + // ---- AI / chatbot robot: a friendly white "helmet" head with a lavender visor, + // two big eyes, side ears, and short antennae that stick OUT of the ears. The + // antennae sit on the sides (not above the head) so the face reads big and central. + let pw = plate.width, ph = plate.height + let cx = plate.midX + let cy = plate.midY + let headW = pw * 0.54 + let headH = ph * 0.54 + let head = CGRect(x: cx - headW / 2, y: cy - headH / 2, width: headW, height: headH) + let headCorner = headW * 0.30 // large radius -> soft, helmet-like silhouette + + // Ears: white rounded tabs on each side at head mid-height. + let earW = pw * 0.085 + let earH = ph * 0.22 + func earRect(_ sign: CGFloat) -> CGRect { + let earX = sign < 0 ? head.minX - earW * 0.45 : head.maxX - earW * 0.55 + return CGRect(x: earX, y: cy - earH / 2, width: earW, height: earH) + } + + // Straight, vertical antennae rising from the ears (knob-tipped). Drawn first so + // the ear covers the join and they read as rooted in the ear. + cg.saveGState() + cg.setStrokeColor(NSColor.white.cgColor) + cg.setLineWidth(max(pw * 0.020, 1.6)) + cg.setLineCap(.round) + for sign in [-1.0, 1.0] as [CGFloat] { + let ear = earRect(sign) + let baseX = ear.midX + let baseY = ear.maxY - earH * 0.10 + let tipX = ear.midX // straight up (no outward lean) + let tipY = head.maxY + ph * 0.050 // rises above the head top + cg.move(to: CGPoint(x: baseX, y: baseY)) + cg.addLine(to: CGPoint(x: tipX, y: tipY)) + cg.strokePath() + let knob = pw * 0.060 + cg.addEllipse(in: CGRect(x: tipX - knob / 2, y: tipY - knob / 2, width: knob, height: knob)) + NSColor.white.setFill(); cg.fillPath() } cg.restoreGState() - // Native cup.and.saucer.fill, white, centered. The cup is the brand object so it - // leads; at small sizes it grows bolder (and heavier weight) so it survives the Dock. - let small = S <= 64 - let cupFrac: CGFloat = small ? 0.66 : 0.58 - let cfg = NSImage.SymbolConfiguration(pointSize: plate.width * (small ? 0.74 : 0.64), - weight: small ? .medium : .regular) - if let sym = NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: nil)? - .withSymbolConfiguration(cfg) { - let sz = sym.size - let scale = (plate.width * cupFrac) / max(sz.width, sz.height) - let w = sz.width * scale, h = sz.height * scale - let r = NSRect(x: plate.midX - w/2, y: plate.midY - h/2, width: w, height: h) - // soft violet chromatic drop shadow for depth (samples the plate mid-stop) - cg.saveGState() - cg.setShadow(offset: CGSize(width: 0, height: -S*0.006), blur: S*0.014, - color: NSColor(srgbRed: 0.32, green: 0.12, blue: 0.50, alpha: 0.55).cgColor) - let tinted = NSImage(size: sz) - tinted.lockFocus(); NSColor.white.set() - sym.draw(in: NSRect(origin: .zero, size: sz)) - NSRect(origin: .zero, size: sz).fill(using: .sourceAtop) - tinted.unlockFocus() - tinted.draw(in: r) - cg.restoreGState() + // Ears drawn over the antenna base. + NSColor.white.setFill() + for sign in [-1.0, 1.0] as [CGFloat] { + let ear = earRect(sign) + cg.addPath(CGPath(roundedRect: ear, cornerWidth: earW * 0.45, cornerHeight: earW * 0.45, transform: nil)) + cg.fillPath() } + + // Head (white) with a soft drop shadow for depth on the glass plate. + cg.saveGState() + cg.setShadow(offset: CGSize(width: 0, height: -S * 0.010), blur: S * 0.022, + color: NSColor(srgbRed: 0.20, green: 0.06, blue: 0.40, alpha: 0.45).cgColor) + cg.addPath(CGPath(roundedRect: head, cornerWidth: headCorner, cornerHeight: headCorner, transform: nil)) + NSColor.white.setFill() + cg.fillPath() + cg.restoreGState() + + // Visor: an inset rounded panel with a subtle lavender -> white vertical gradient. + let visor = head.insetBy(dx: headW * 0.135, dy: headH * 0.150) + let visorCorner = visor.width * 0.34 + cg.saveGState() + cg.addPath(CGPath(roundedRect: visor, cornerWidth: visorCorner, cornerHeight: visorCorner, transform: nil)) + cg.clip() + let visorGrad = CGGradient(colorsSpace: cs, + colors: [NSColor(srgbRed: 214/255.0, green: 204/255.0, blue: 255/255.0, alpha: 1).cgColor, // top: light lavender + NSColor.white.cgColor] as CFArray, // bottom: white + locations: [0, 1])! + cg.drawLinearGradient(visorGrad, + start: CGPoint(x: visor.midX, y: visor.maxY), + end: CGPoint(x: visor.midX, y: visor.minY), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation]) + cg.restoreGState() + + // Eyes: two big, dark-violet dots centered in the visor. + let eye = NSColor(srgbRed: 59/255.0, green: 30/255.0, blue: 120/255.0, alpha: 1) + eye.setFill() + let eyeR = headW * 0.075 + let eyeDX = headW * 0.15 + let eyeY = visor.midY + visor.height * 0.02 + for sign in [-1.0, 1.0] as [CGFloat] { + cg.addEllipse(in: CGRect(x: cx + sign * eyeDX - eyeR, y: eyeY - eyeR, width: eyeR * 2, height: eyeR * 2)) + cg.fillPath() + } + NSGraphicsContext.restoreGraphicsState() return rep } diff --git a/reset-agent-setup.sh b/reset-agent-setup.sh new file mode 100755 index 0000000..eb2dd4b --- /dev/null +++ b/reset-agent-setup.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# reset-agent-setup.sh — remove only Sleepless Agents' 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..421cb53 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 -APP_NAME="Sleepless" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_NAME="Sleepless Agents" 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 "Sleepless Agents uninstaller" +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 Agents, 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"