From 4c6601c318bd377d5cfee7d0d0e5ee6f75c1130b Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Sun, 6 Sep 2026 05:24:14 +0300
Subject: [PATCH 1/6] Add protected lid sessions with one-time privileged
helper setup
---
.gitignore | 5 +
CHANGELOG.md | 10 +
README.md | 14 +
docs/PROTECTED-LID.md | 126 ++++++++
scripts/build-lid.sh | 24 ++
scripts/uninstall-lid-helper.sh | 20 ++
src/Caffeine/Classes/AppDelegate.swift | 2 +-
.../Classes/Models/ActivitySimulator.swift | 17 +-
.../Classes/Models/ProtectedLidManager.swift | 298 ++++++++++++++++++
.../ViewModels/CaffeineViewModel.swift | 28 +-
.../Classes/Views/MenuBarController.swift | 33 +-
.../Classes/Views/PreferencesView.swift | 5 +
.../Classes/Views/ProtectedLidControls.swift | 40 +++
src/Caffeine/Resources/Caffeine.entitlements | 2 +-
src/Caffeine/Resources/Info.plist | 4 -
.../Resources/ar.lproj/Localizable.strings | 40 +++
.../Resources/en.lproj/Localizable.strings | 13 +
.../Resources/protected-lid-watchdog.sh | 110 +++++++
tests/test_lid_watchdog.py | 222 +++++++++++++
19 files changed, 981 insertions(+), 32 deletions(-)
create mode 100644 docs/PROTECTED-LID.md
create mode 100755 scripts/build-lid.sh
create mode 100755 scripts/uninstall-lid-helper.sh
create mode 100644 src/Caffeine/Classes/Models/ProtectedLidManager.swift
create mode 100644 src/Caffeine/Classes/Views/ProtectedLidControls.swift
create mode 100644 src/Caffeine/Resources/ar.lproj/Localizable.strings
create mode 100644 src/Caffeine/Resources/protected-lid-watchdog.sh
create mode 100644 tests/test_lid_watchdog.py
diff --git a/.gitignore b/.gitignore
index 23137f2..26df758 100755
--- a/.gitignore
+++ b/.gitignore
@@ -73,3 +73,8 @@ iOSInjectionProject/
/sparkle/framework/sparkle.app
/sparkle/framework/Sparkle.framework
/sparkle/release
+
+# Local builds and test caches
+.build/
+dist/
+__pycache__/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7e3713..bdf0476 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Experimental protected lid sessions: lock macOS first, keep background work running with the lid closed, and request display sleep. Unlocking ends the protected session.
+- First-use administrator installation of a limited persistent helper; later sessions do not repeat the administrator prompt.
+- Short heartbeat lease, client/helper crash recovery, timer/quit cleanup, and low-battery/thermal stopping.
+- Arabic interface, protected-mode controls, isolated watchdog tests, universal build script, helper removal, and hardware validation instructions.
+
+
### Changed
+- Disabled upstream automatic updates for this separately identified direct-distribution fork.
+
- Improved Ukrainian translation.
### Fixed
diff --git a/README.md b/README.md
index e48715d..6172c55 100755
--- a/README.md
+++ b/README.md
@@ -1,3 +1,17 @@
+# Caffeine Lid — قفل الجهاز والعمل والغطاء مغلق
+
+Fork of [domzilla/Caffeine](https://github.com/domzilla/Caffeine) with an experimental protected lid session for macOS.
+
+**العربية:** من الإعدادات اختر «قفل الجهاز والعمل والغطاء مغلق…». يُطلب إذن المسؤول مرة واحدة لتثبيت المساعد. يقفل جهازك فورًا ثم يبقي المهام تعمل مع إطفاء الشاشات، ويمكنك إغلاق الغطاء. عند العودة يلزم فتح قفل حساب Mac، وتنتهي جلسة الغطاء المحمي بعد فتح القفل. يلزم اختبار الغطاء والطاقة فعليًا على جهازك؛ هذه نسخة تجريبية وليست إصدارًا موثقًا من Apple.
+
+**English:** A dedicated action locks macOS before keeping work running with the lid closed and displays off. Administrator approval is needed only for the first helper installation. Unlocking ends the protected session. Hardware verification is still required.
+
+Build with `bash scripts/build-lid.sh`. Read [usage, helper removal, recovery, and validation](docs/PROTECTED-LID.md) before testing.
+
+---
+
+## Upstream README
+
# Caffeine
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
new file mode 100644
index 0000000..314bcba
--- /dev/null
+++ b/docs/PROTECTED-LID.md
@@ -0,0 +1,126 @@
+# Protected lid sessions
+
+This fork adds an **experimental direct-distribution** feature for Mac laptops.
+The ordinary coffee-cup toggle retains its existing behavior. The separate
+**Lock & keep awake with lid closed…** action starts a protected session.
+
+## Use
+
+1. Build with `bash scripts/build-lid.sh` and open `dist/Caffeine Lid.app`.
+ It uses a separate bundle identifier and preferences from upstream Caffeine.
+ Quit the original Caffeine while using this fork: another display assertion
+ or simulated mouse activity can interfere with turning the displays off.
+2. Open preferences from the menu bar, choose a duration if desired, and select
+ **Lock & keep awake with lid closed…**.
+3. **The first session only** asks macOS to install a small privileged helper.
+ Authenticate in the native macOS administrator dialog. Caffeine never reads,
+ stores, or creates a password. Canceling leaves lid sleep unchanged.
+4. The Mac locks immediately. After the lock is confirmed, the helper disables
+ system sleep and Caffeine requests display sleep. Close the lid when the
+ lock screen/display turns off. Background work can continue on battery or AC.
+5. Opening the lid reveals the native lock screen. Use the account password or
+ the authentication methods permitted by macOS, such as Touch ID.
+ **Unlocking ends the protected session** and resumes ordinary Caffeine.
+ Start a new protected session before closing the lid again.
+
+Locking before enabling the override avoids relying on a notification after the
+display has already become visible. This mode intentionally locks immediately;
+it does not let you keep editing with an unlocked screen until lid closure.
+It turns off external displays too. It does not shut down the Mac or log out.
+
+## Lifetime and recovery
+
+- Caffeine holds a short lease that it renews only while the screen is confirmed
+ locked. Stop, Quit, timer expiry, unlock, or failure removes that lease.
+- The helper restores normal sleep on its next poll, normally within one second.
+ If Caffeine hangs, the lease expires after ten seconds. PID, UID, process start
+ time, a fresh UUID, and a pending-to-locked handshake guard against stale requests.
+- launchd restarts the helper after failure. A root-owned durable ownership marker
+ triggers restoration after a helper crash or reboot, before accepting a session.
+- A pre-existing `SleepDisabled` override is treated as a conflict and left alone.
+ Avoid running other tools that change the same global setting during a session;
+ macOS does not provide independent ownership of that setting for each app.
+- The app ends the protected session at 10% battery on battery power, or on a
+ serious/critical thermal state. Hardware protection and shutdown cannot and
+ should not be overridden. Keep ventilation clear; do not run it in a closed bag.
+
+## Helper scope
+
+The first-use installer writes these fixed locations with administrator approval:
+
+- `/Library/PrivilegedHelperTools/net.ziyad.caffeine.lid-helper.sh`
+- `/Library/LaunchDaemons/net.ziyad.caffeine.lid-helper.plist`
+- `/Library/Application Support/CaffeineLid/`
+
+The script, plist, ownership marker, and status are root-owned. A single request
+directory is owned by the installing user's numeric UID with mode 0700. The helper
+accepts bounded data from that user only, validates it, and runs fixed `pmset`
+commands. It never executes a client-supplied command or sources a user-writable
+script. No sudoers exception is installed. Other processes running as that same
+user can request the same limited power operation; this is not code-signature
+authenticated IPC. The helper supports one installing account and one session.
+
+The helper remains installed and idle between sessions, so subsequent sessions
+do not require another administrator prompt. A future helper upgrade or removal
+may require administrator approval. Removing the app alone does not remove the
+helper. After quitting the app, uninstall it with:
+
+```sh
+sudo /bin/sh scripts/uninstall-lid-helper.sh
+```
+
+If powerd or launchd cannot restore sleep, recovery is:
+
+```sh
+sudo pmset -a disablesleep 0
+```
+
+## Build, tests, and limitations
+
+The build script preserves the upstream Xcode project and overrides its sandbox
+and bundle identifier at build time. The privileged feature requires a build
+outside App Sandbox. Upstream automatic updates are disabled so they cannot
+replace this fork. Builds are ad-hoc signed, not Developer ID signed or notarized.
+They target macOS 14.6 or newer and include Apple Silicon and Intel code.
+
+```sh
+python3 -m unittest discover -s tests -v
+bash scripts/build-lid.sh
+swiftformat .
+```
+
+New UI is localized in English and Arabic; other existing languages fall back to
+English for the new strings. Upstream developer guides referenced by AGENTS.md
+are not included in the repository; existing style and SwiftFormat are used.
+
+Automated tests run the actual watchdog with an isolated fake power backend;
+they never change real power settings, install the helper, or lock the computer.
+They cover confirmed-lock gating, stale leases, client and helper crashes,
+termination, conflicting overrides, invalid input, and failed enable/restore.
+
+Physical lid closure, panel darkness, uninterrupted work on battery/AC,
+authentication on reopening, the first administrator prompt and subsequent
+prompt-free sessions still require manual hardware validation. The lock operation
+uses the private macOS `SACLockScreenImmediate` symbol and confirmation uses
+`CGSSessionScreenIsLocked`; either can change in macOS updates. Missing lock
+support or confirmation aborts the session. Successful compilation and mocked
+tests do not establish hardware compatibility or a production security guarantee.
+
+### Manual acceptance checklist
+
+- Cancel first-use authorization: no helper and no power override.
+- Approve once, stop, start again: no second administrator prompt.
+- Start a background timestamp log, activate, close the lid on battery and AC:
+ timestamps continue and the built-in panel is off.
+- Open the lid: no desktop is exposed before authentication; unlock ends mode.
+- Stop, timeout, quit, force-quit, and stop client heartbeat: `pmset -g` returns
+ `SleepDisabled 0` within the documented window.
+- Kill the helper during a session: launchd restarts it and restores the override.
+- Reboot during a session: recovery runs, and no protected session starts itself.
+- Check low battery, thermal stop, an existing external override, and uninstall.
+
+## References
+
+- [Apple PowerManagement implementation of pmset](https://github.com/apple-oss-distributions/PowerManagement/blob/main/pmset/pmset.m)
+- [Apple's IOPM definitions, including lid state](https://github.com/apple-oss-distributions/IOKitUser/blob/main/pwr_mgt.subproj/IOPM.h)
+- [Lock function and lock-state example](https://gist.github.com/pudquick/9797a9ce8ad97de6e326afc7c9894965)
diff --git a/scripts/build-lid.sh b/scripts/build-lid.sh
new file mode 100755
index 0000000..73fcf45
--- /dev/null
+++ b/scripts/build-lid.sh
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Build this direct-distribution fork without changing the upstream Xcode project.
+set -euo pipefail
+repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
+output_dir="${1:-$repo_dir/dist}"
+mkdir -p "$output_dir"
+output_dir="$(cd "$output_dir" && pwd)"
+xcodebuild -project "$repo_dir/src/Caffeine.xcodeproj" -scheme Caffeine \
+ -configuration Release -destination 'generic/platform=macOS' \
+ -derivedDataPath "$repo_dir/.build/lid" \
+ ARCHS='arm64 x86_64' ONLY_ACTIVE_ARCH=NO \
+ ENABLE_APP_SANDBOX=NO PRODUCT_BUNDLE_IDENTIFIER=net.ziyad.caffeine.lid \
+ CODE_SIGNING_ALLOWED=NO build
+app="$output_dir/Caffeine Lid.app"
+if [ -e "$app" ]; then
+ echo "Output already exists: $app. Choose an empty output directory." >&2
+ exit 1
+fi
+ditto "$repo_dir/.build/lid/Build/Products/Release/Caffeine.app" "$app"
+# A local build is ad-hoc signed, not Developer ID signed or notarized.
+codesign --force --deep --sign - "$app"
+codesign --verify --deep --strict "$app"
+ditto -c -k --sequesterRsrc --keepParent "$app" "$output_dir/Caffeine-Lid-macOS.zip"
+echo "Built: $output_dir/Caffeine-Lid-macOS.zip"
diff --git a/scripts/uninstall-lid-helper.sh b/scripts/uninstall-lid-helper.sh
new file mode 100755
index 0000000..223372d
--- /dev/null
+++ b/scripts/uninstall-lid-helper.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+# Run with sudo after quitting Caffeine. Removes only this fork's helper.
+set -eu
+if [ "$(/usr/bin/id -u)" != 0 ]; then
+ echo 'Run this script with sudo after quitting Caffeine.' >&2
+ exit 1
+fi
+label=net.ziyad.caffeine.lid-helper
+state='/Library/Application Support/CaffeineLid'
+/bin/launchctl bootout "system/$label" 2>/dev/null || true
+# The daemon normally restores on exit. Also recover a prior hard crash.
+if [ -f "$state/owned" ]; then
+ /usr/bin/pmset -a disablesleep 0
+ /bin/rm -f "$state/owned"
+fi
+/bin/rm -f "/Library/LaunchDaemons/$label.plist" "/Library/PrivilegedHelperTools/$label.sh"
+/bin/rm -f "$state/status" "$state/owner" "$state/version" "$state/request/lease"
+if [ -d "$state/request" ]; then /bin/rmdir "$state/request"; fi
+if [ -d "$state" ]; then /bin/rmdir "$state"; fi
+echo 'Caffeine lid helper removed.'
diff --git a/src/Caffeine/Classes/AppDelegate.swift b/src/Caffeine/Classes/AppDelegate.swift
index 5a9ca3f..60216df 100644
--- a/src/Caffeine/Classes/AppDelegate.swift
+++ b/src/Caffeine/Classes/AppDelegate.swift
@@ -12,7 +12,7 @@ import SwiftUI
class AppDelegate: NSObject, NSApplicationDelegate, SPUStandardUserDriverDelegate {
/// Make this lazy so `self` can be used safely
private lazy var updaterController = SPUStandardUpdaterController(
- startingUpdater: true,
+ startingUpdater: false,
updaterDelegate: nil,
userDriverDelegate: self
)
diff --git a/src/Caffeine/Classes/Models/ActivitySimulator.swift b/src/Caffeine/Classes/Models/ActivitySimulator.swift
index 433f6b2..1c87c91 100644
--- a/src/Caffeine/Classes/Models/ActivitySimulator.swift
+++ b/src/Caffeine/Classes/Models/ActivitySimulator.swift
@@ -31,16 +31,13 @@ final class ActivitySimulator {
func startMonitoring() {
self.stopMonitoring()
- // Ensure timer is scheduled on main run loop
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
-
- self.checkTimer = Timer.scheduledTimer(
- withTimeInterval: self.checkInterval,
- repeats: true
- ) { [weak self] _ in
- self?.checkAndSimulateIfNeeded()
- }
+ // Callers are on the main actor. Schedule synchronously so a following
+ // stop cannot be undone by a previously queued start during screen lock.
+ self.checkTimer = Timer.scheduledTimer(
+ withTimeInterval: self.checkInterval,
+ repeats: true
+ ) { [weak self] _ in
+ self?.checkAndSimulateIfNeeded()
}
}
diff --git a/src/Caffeine/Classes/Models/ProtectedLidManager.swift b/src/Caffeine/Classes/Models/ProtectedLidManager.swift
new file mode 100644
index 0000000..7773872
--- /dev/null
+++ b/src/Caffeine/Classes/Models/ProtectedLidManager.swift
@@ -0,0 +1,298 @@
+import AppKit
+import Combine
+import IOKit
+import IOKit.ps
+
+/// A protected session is locked BEFORE lid sleep is disabled. It ends on
+/// authentication, so opening the lid can never expose an unlocked desktop.
+@MainActor
+final class ProtectedLidManager: ObservableObject {
+ @Published private(set) var isPreparing = false
+ @Published private(set) var isRunning = false
+ @Published var errorMessage: String?
+ var didStop: (() -> Void)?
+
+ private var sessionID: UUID?
+ private var leaseURL: URL?
+ private var monitor: Task?
+ private let loginLibrary = dlopen("/System/Library/PrivateFrameworks/login.framework/login", RTLD_LAZY)
+ private let helperDirectory = URL(fileURLWithPath: "/Library/Application Support/CaffeineLid", isDirectory: true)
+ private var statusURL: URL {
+ self.helperDirectory.appendingPathComponent("status")
+ }
+
+ var isEngaged: Bool {
+ self.isPreparing || self.isRunning
+ }
+
+ func start() async {
+ guard !self.isEngaged else { return }
+ self.errorMessage = nil
+ guard Self.lidIsClosed() != nil else {
+ self.errorMessage = String(localized: "Protected lid mode requires a Mac laptop.")
+ self.didStop?()
+ return
+ }
+ guard
+ let library = self.loginLibrary,
+ let symbol = dlsym(library, "SACLockScreenImmediate") else
+ {
+ self
+ .errorMessage =
+ String(localized: "The macOS lock service is unavailable. Protected lid mode was not enabled.")
+ self.didStop?()
+ return
+ }
+ let id = UUID()
+ self.sessionID = id
+ self.isPreparing = true
+ do {
+ if !self.helperIsInstalled() {
+ guard let scriptURL = Bundle.main.url(forResource: "protected-lid-watchdog", withExtension: "sh") else {
+ throw LidError.unavailable
+ }
+ let script = try String(contentsOf: scriptURL, encoding: .utf8)
+ try await Self.authorize(Self.installCommand(script: script))
+ }
+ guard self.sessionID == id else { return }
+ // An existing session belongs to another running copy of Caffeine.
+ for _ in 0..<50 {
+ let status = self.readStatus()
+ if self.helperIsInstalled(), status != nil, status != "offline" {
+ break
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ guard
+ self.helperIsInstalled(),
+ let status = self.readStatus(), status != "offline",
+ !status.hasPrefix("active:"), !status.hasPrefix("ready:") else
+ {
+ throw LidError.unavailable
+ }
+ self.leaseURL = self.helperDirectory.appendingPathComponent("request/lease")
+ try self.refreshLease(locked: false)
+ try await self.waitForStatus("ready", id: id)
+ guard self.sessionID == id else { return }
+
+ typealias LockFunction = @convention(c) () -> Void
+ let lock = unsafeBitCast(symbol, to: LockFunction.self)
+ lock()
+ var confirmed = false
+ for _ in 0..<50 {
+ guard self.sessionID == id else { return }
+ if Self.isScreenLocked() == true {
+ confirmed = true
+ break
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ guard confirmed else { throw LidError.unavailable }
+ try self.refreshLease(locked: true)
+ try await self.waitForStatus("active", id: id)
+ guard self.sessionID == id else { return }
+ guard Self.isScreenLocked() == true else { throw LidError.unavailable }
+ self.isPreparing = false
+ self.isRunning = true
+ self.sleepDisplays()
+ self.monitor = Task { [weak self] in
+ var wasClosed = Self.lidIsClosed()
+ while !Task.isCancelled {
+ do { try await Task.sleep(for: .seconds(1)) } catch { return }
+ guard let self, self.sessionID == id else { return }
+ // Unknown lock state fails closed: stop renewing the lease.
+ guard Self.isScreenLocked() == true else { self.stop()
+ return
+ }
+ guard self.readStatus() == "active:\(id.uuidString)" else {
+ self.fail()
+ return
+ }
+ guard !Self.shouldStopForPower() else {
+ self.stop()
+ self
+ .errorMessage =
+ String(
+ localized: "Protected lid mode stopped because the battery is low or the Mac is too warm."
+ )
+ return
+ }
+ do { try self.refreshLease(locked: true) } catch { self.fail()
+ return
+ }
+ let closed = Self.lidIsClosed()
+ guard closed != nil else { self.fail()
+ return
+ }
+ if closed == true, wasClosed != true {
+ self.sleepDisplays()
+ }
+ wasClosed = closed
+ }
+ }
+ } catch {
+ guard self.sessionID == id else { return }
+ self.fail()
+ }
+ }
+
+ func stop() {
+ let wasEngaged = self.isEngaged
+ let oldID = self.sessionID
+ self.sessionID = nil
+ self.monitor?.cancel()
+ self.monitor = nil
+ if
+ let leaseURL, let oldID,
+ let value = try? String(contentsOf: leaseURL, encoding: .utf8),
+ value.hasSuffix(":" + oldID.uuidString)
+ {
+ try? FileManager.default.removeItem(at: leaseURL)
+ }
+ self.leaseURL = nil
+ self.isRunning = false
+ self.isPreparing = false
+ // Keep the dynamically loaded lock function valid for the app lifetime.
+ if wasEngaged {
+ self.didStop?()
+ }
+ }
+
+ private func fail() {
+ self.stop()
+ self
+ .errorMessage =
+ String(
+ localized: "Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again."
+ )
+ }
+
+ private func refreshLease(locked: Bool) throws {
+ guard let leaseURL, let sessionID else { throw LidError.unavailable }
+ let value = "\(locked ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(sessionID.uuidString)"
+ try value.write(to: leaseURL, atomically: true, encoding: .utf8)
+ }
+
+ private func readStatus() -> String? {
+ try? String(contentsOf: self.statusURL, encoding: .utf8)
+ }
+
+ private func waitForStatus(_ status: String, id: UUID) async throws {
+ for _ in 0..<50 {
+ guard self.sessionID == id else { throw CancellationError() }
+ if self.readStatus() == "\(status):\(id.uuidString)" {
+ return
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ throw LidError.unavailable
+ }
+
+ private func sleepDisplays() {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
+ process.arguments = ["displaysleepnow"]
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try? process.run()
+ }
+
+ private func helperIsInstalled() -> Bool {
+ let owner = try? String(contentsOf: self.helperDirectory.appendingPathComponent("owner"), encoding: .utf8)
+ let version = try? String(contentsOf: self.helperDirectory.appendingPathComponent("version"), encoding: .utf8)
+ return owner == String(getuid()) && version == "1"
+ }
+
+ private nonisolated static func installCommand(script: String) -> String {
+ let uid = getuid()
+ let label = "net.ziyad.caffeine.lid-helper"
+ let helper = "/Library/PrivilegedHelperTools/" + label + ".sh"
+ let plist = "/Library/LaunchDaemons/" + label + ".plist"
+ let configuration = """
+
+
+
+ Label\(label)
+ ProgramArguments/bin/sh\(helper)\(uid)
+ RunAtLoadKeepAlive
+ ThrottleInterval5
+ ProcessTypeBackground
+
+ """
+ // Every destination is fixed and root-owned; only a numeric UID and
+ // the bundled script/plist are inserted, with POSIX shell quoting.
+ return """
+ set -eu
+ state='/Library/Application Support/CaffeineLid'
+ if [ -f "$state/owner" ] && [ "$(/bin/cat "$state/owner")" != '\(uid)' ]; then exit 1; fi
+ /bin/mkdir -p /Library/PrivilegedHelperTools /Library/LaunchDaemons "$state"
+ /usr/sbin/chown root:wheel "$state"
+ /bin/chmod 755 "$state"
+ /bin/launchctl bootout system/\(label) 2>/dev/null || true
+ /bin/mkdir -p "$state/request"
+ /usr/sbin/chown \(uid) "$state/request"
+ /bin/chmod 700 "$state/request"
+ /usr/bin/printf '%s' \(Self.shellQuote(script)) > \(Self.shellQuote(helper))
+ /usr/bin/printf '%s' \(Self.shellQuote(configuration)) > \(Self.shellQuote(plist))
+ /usr/sbin/chown root:wheel \(Self.shellQuote(helper)) \(Self.shellQuote(plist))
+ /bin/chmod 755 \(Self.shellQuote(helper))
+ /bin/chmod 644 \(Self.shellQuote(plist))
+ /bin/launchctl bootstrap system \(Self.shellQuote(plist))
+ """
+ }
+
+ private nonisolated static func shellQuote(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
+ }
+
+ private nonisolated static func authorize(_ command: String) async throws {
+ let escaped = command.replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ try await Task.detached {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
+ process.arguments = ["-e", "do shell script \"\(escaped)\" with administrator privileges"]
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try process.run()
+ process.waitUntilExit()
+ guard process.terminationStatus == 0 else { throw LidError.unavailable }
+ }.value
+ }
+
+ private static func isScreenLocked() -> Bool? {
+ guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else { return nil }
+ return session["CGSSessionScreenIsLocked"] as? Bool
+ }
+
+ private static func lidIsClosed() -> Bool? {
+ let root = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain"))
+ guard root != 0 else { return nil }
+ defer { IOObjectRelease(root) }
+ return IORegistryEntryCreateCFProperty(root, "AppleClamshellState" as CFString, kCFAllocatorDefault, 0)?
+ .takeRetainedValue() as? Bool
+ }
+
+ private static func shouldStopForPower() -> Bool {
+ if [.serious, .critical].contains(ProcessInfo.processInfo.thermalState) {
+ return true
+ }
+ guard
+ let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
+ let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] else { return false }
+ for source in sources {
+ guard
+ let values = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() as? [String: Any],
+ values[kIOPSTransportTypeKey] as? String == kIOPSInternalType,
+ values[kIOPSPowerSourceStateKey] as? String == kIOPSBatteryPowerValue,
+ let current = values[kIOPSCurrentCapacityKey] as? Int,
+ let maximum = values[kIOPSMaxCapacityKey] as? Int, maximum > 0 else { continue }
+ if Double(current) / Double(maximum) <= 0.10 {
+ return true
+ }
+ }
+ return false
+ }
+
+ private enum LidError: Error { case unavailable }
+}
diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
index 407c4d1..8d3524d 100644
--- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
+++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
@@ -17,6 +17,7 @@ class CaffeineViewModel: ObservableObject {
@Published var isActive = false
@Published var timeRemaining: TimeInterval?
@Published var showPreferences = false
+ let protectedLid = ProtectedLidManager()
// MARK: - Private Properties
@@ -32,6 +33,11 @@ class CaffeineViewModel: ObservableObject {
self.timeRemaining = nil
self.setupObservers()
+ self.protectedLid.didStop = { [weak self] in
+ guard let self, self.isActive else { return }
+ SleepPreventionManager.shared.preventSleep()
+ self.updateActivitySimulation(enabled: UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive))
+ }
// Check if we should activate at launch
if UserDefaults.standard.bool(forKey: PreferenceKeys.activateAtLaunch) {
@@ -108,9 +114,11 @@ class CaffeineViewModel: ObservableObject {
}
self.isActive = true
- SleepPreventionManager.shared.preventSleep()
+ if !self.protectedLid.isEngaged {
+ SleepPreventionManager.shared.preventSleep()
+ }
- if UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) {
+ if !self.protectedLid.isEngaged, UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) {
ActivitySimulator.shared.startMonitoring()
}
}
@@ -120,19 +128,31 @@ class CaffeineViewModel: ObservableObject {
self.cancelTimers()
self.timeRemaining = nil
self.isActive = false
+ self.protectedLid.stop()
+ SleepPreventionManager.shared.allowSleep()
+ ActivitySimulator.shared.stopMonitoring()
+ }
+
+ /// Explicit action: authenticate first, then lock before allowing lid closure.
+ func startProtectedLidSession() {
+ guard !self.protectedLid.isEngaged else { return }
+ if !self.isActive {
+ self.activate()
+ }
SleepPreventionManager.shared.allowSleep()
ActivitySimulator.shared.stopMonitoring()
+ Task { await self.protectedLid.start() }
}
/// Updates activity simulation based on preference
func updateActivitySimulation(enabled: Bool) {
- if enabled {
+ if enabled, !self.protectedLid.isEngaged {
// Trigger the Accessibility permission prompt by posting a no-op event
// This prompts for "Events" permission which CGEvent.post requires
ActivitySimulator.shared.requestPermission()
}
- if enabled, self.isActive {
+ if enabled, self.isActive, !self.protectedLid.isEngaged {
ActivitySimulator.shared.startMonitoring()
} else {
ActivitySimulator.shared.stopMonitoring()
diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift
index 8747766..dff1ab2 100644
--- a/src/Caffeine/Classes/Views/MenuBarController.swift
+++ b/src/Caffeine/Classes/Views/MenuBarController.swift
@@ -37,6 +37,16 @@ class MenuBarController: NSObject {
}
}
+ @objc
+ private func toggleProtectedLid() {
+ if self.viewModel.protectedLid.isEngaged {
+ self.viewModel.protectedLid.stop()
+ } else {
+ // Show the explanation and explicit lock action before starting.
+ self.showPreferencesWindow()
+ }
+ }
+
private func setupMenuBar() {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -98,6 +108,16 @@ class MenuBarController: NSObject {
menu.addItem(NSMenuItem.separator())
}
+ let lidItem = NSMenuItem(
+ title: self.viewModel.protectedLid.isEngaged
+ ? String(localized: "Stop protected lid mode")
+ : String(localized: "Lock & keep awake with lid closed…"),
+ action: #selector(self.toggleProtectedLid), keyEquivalent: ""
+ )
+ lidItem.target = self
+ menu.addItem(lidItem)
+ menu.addItem(NSMenuItem.separator())
+
// Duration options in submenu
let activateForItem = NSMenuItem(
title: String(localized: "Activate for"),
@@ -155,17 +175,6 @@ class MenuBarController: NSObject {
aboutItem.target = self
menu.addItem(aboutItem)
- // Update
- let updatesItem = NSMenuItem(
- title: String(localized: "Check for Updates..."),
- action: #selector(checkForUpdates(_:)),
- keyEquivalent: ""
- )
- updatesItem.target = self
- menu.addItem(updatesItem)
-
- menu.addItem(NSMenuItem.separator())
-
// Quit
let quitItem = NSMenuItem(
title: String(localized: "Quit"),
@@ -207,7 +216,7 @@ class MenuBarController: NSObject {
let window = NSWindow(contentViewController: hostingController)
window.title = String(localized: "Welcome to Caffeine")
window.styleMask = [.titled, .closable]
- window.setContentSize(NSSize(width: 640, height: 420))
+ window.setContentSize(hostingController.view.fittingSize)
window.center()
self.preferencesWindow = window
diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift
index 9b9c2d1..c6b5667 100644
--- a/src/Caffeine/Classes/Views/PreferencesView.swift
+++ b/src/Caffeine/Classes/Views/PreferencesView.swift
@@ -97,6 +97,11 @@ struct PreferencesView: View {
.padding(.leading, 20)
}
+ Divider().padding(.vertical, 12)
+ ProtectedLidControls(manager: self.viewModel.protectedLid) {
+ self.viewModel.startProtectedLidSession()
+ }
+
Spacer()
.frame(height: 30)
diff --git a/src/Caffeine/Classes/Views/ProtectedLidControls.swift b/src/Caffeine/Classes/Views/ProtectedLidControls.swift
new file mode 100644
index 0000000..bde5b3a
--- /dev/null
+++ b/src/Caffeine/Classes/Views/ProtectedLidControls.swift
@@ -0,0 +1,40 @@
+import SwiftUI
+
+struct ProtectedLidControls: View {
+ @ObservedObject var manager: ProtectedLidManager
+ let start: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Protected lid mode").font(.headline)
+ Text(
+ "Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode."
+ )
+ .font(.system(size: 12))
+ .fixedSize(horizontal: false, vertical: true)
+ Text(
+ "Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep."
+ )
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if self.manager.isPreparing {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Preparing protected lid mode…")
+ }
+ } else if self.manager.isRunning {
+ Text("Protected lid mode is active.").foregroundStyle(.secondary)
+ }
+ if let error = self.manager.errorMessage {
+ Text(error).font(.system(size: 11)).foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ if self.manager.isEngaged {
+ Button("Stop protected lid mode") { self.manager.stop() }
+ } else {
+ Button("Lock & keep awake with lid closed…", action: self.start)
+ }
+ }
+ }
+}
diff --git a/src/Caffeine/Resources/Caffeine.entitlements b/src/Caffeine/Resources/Caffeine.entitlements
index 3644fae..15db92c 100644
--- a/src/Caffeine/Resources/Caffeine.entitlements
+++ b/src/Caffeine/Resources/Caffeine.entitlements
@@ -3,7 +3,7 @@
com.apple.security.app-sandbox
-
+
com.apple.security.files.user-selected.read-only
com.apple.security.network.client
diff --git a/src/Caffeine/Resources/Info.plist b/src/Caffeine/Resources/Info.plist
index b5d1f69..be1b4ee 100644
--- a/src/Caffeine/Resources/Info.plist
+++ b/src/Caffeine/Resources/Info.plist
@@ -4,9 +4,5 @@
SUEnableInstallerLauncherService
- SUFeedURL
- https://dr-caffeine-mac.s3.amazonaws.com/appcast.xml
- SUPublicEDKey
- tfFOu6jc/ABVAmXA0xfDMUXxCgKOFQaZ+i2JH4vyKhI=
diff --git a/src/Caffeine/Resources/ar.lproj/Localizable.strings b/src/Caffeine/Resources/ar.lproj/Localizable.strings
new file mode 100644
index 0000000..d7bf0f5
--- /dev/null
+++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings
@@ -0,0 +1,40 @@
+"1 minute" = "دقيقة واحدة";
+"5 minutes" = "٥ دقائق";
+"10 minutes" = "١٠ دقائق";
+"15 minutes" = "١٥ دقيقة";
+"30 minutes" = "٣٠ دقيقة";
+"1 hour" = "ساعة واحدة";
+"2 hours" = "ساعتان";
+"5 hours" = "٥ ساعات";
+"Indefinitely" = "بلا مدة محددة";
+"Caffeine is active" = "Caffeine مفعّل";
+"%d minutes" = "%d دقيقة";
+"%d seconds" = "%d ثانية";
+"Check for Updates..." = "البحث عن تحديثات…";
+"Preferences..." = "الإعدادات…";
+"About Caffeine" = "حول Caffeine";
+"Quit" = "إنهاء";
+"Caffeine is now running. You can find its icon in the right side of your menu bar. Click it to disable automatic sleep, click it again to enable automatic sleep." = "تطبيق Caffeine يعمل الآن. ستجد أيقونته في شريط القوائم. انقر عليها لمنع السكون التلقائي، وانقر مرة أخرى للسماح به.";
+"Right-click (or ⌃-click) the menu bar icon to show the Caffeine menu." = "انقر بزر الفأرة الأيمن (أو مع الضغط على ⌃) على الأيقونة لعرض قائمة Caffeine.";
+"Default duration:" = "المدة الافتراضية:";
+"Activate when starting Caffeine" = "التفعيل عند تشغيل Caffeine";
+"Deactivate when device goes to sleep manually" = "إلغاء التفعيل عند إدخال الجهاز في السكون يدويًا";
+"Show this message when starting Caffeine" = "إظهار هذه الرسالة عند تشغيل Caffeine";
+"Keep apps active" = "إبقاء التطبيقات نشطة";
+"Prevents apps from becoming inactive and the screen saver from starting." = "يمنع التطبيقات من التحول إلى حالة غير نشطة ويمنع بدء شاشة التوقف.";
+"Close" = "إغلاق";
+"Activate for" = "التفعيل لمدة";
+"Welcome to Caffeine" = "مرحبًا بك في Caffeine";
+"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net";
+"Caffeine prevents sleep" = "Caffeine يمنع السكون";
+"Protected lid mode requires a Mac laptop." = "يتطلب وضع الغطاء المحمي جهاز Mac محمولًا.";
+"The macOS lock service is unavailable. Protected lid mode was not enabled." = "خدمة قفل macOS غير متاحة. لم يتم تفعيل وضع الغطاء المحمي.";
+"Protected lid mode stopped because the battery is low or the Mac is too warm." = "توقف وضع الغطاء المحمي بسبب انخفاض البطارية أو ارتفاع حرارة الجهاز.";
+"Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again." = "تعذر التأكد من تشغيل وضع الغطاء المحمي، فتم إيقافه. وافق على طلب صلاحية المسؤول وتأكد من عدم وجود تطبيق آخر يمنع السكون عند إغلاق الغطاء، ثم أعد المحاولة.";
+"Protected lid mode" = "وضع الغطاء المحمي";
+"Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode." = "يقفل جهازك الآن، ثم يبقي المهام تعمل والغطاء مغلق والشاشات مطفأة. عند العودة، افتح القفل بكلمة مرور حساب Mac أو Touch ID. ينتهي هذا الوضع عند فتح القفل.";
+"Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep." = "يتطلب موافقة المسؤول مرة واحدة لتثبيت مساعد صغير. لن تطلب الجلسات التالية صلاحية المسؤول مجددًا. يعود السكون الطبيعي عند إيقاف Caffeine أو انتهاء المؤقت أو إغلاق التطبيق. أبقِ الجهاز في مكان جيد التهوية؛ فهذا الوضع يستهلك بطارية أكثر من السكون.";
+"Preparing protected lid mode…" = "جارٍ إعداد وضع الغطاء المحمي…";
+"Protected lid mode is active." = "وضع الغطاء المحمي مفعّل.";
+"Stop protected lid mode" = "إيقاف وضع الغطاء المحمي";
+"Lock & keep awake with lid closed…" = "قفل الجهاز والعمل والغطاء مغلق…";
diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings
index 66e5e4c..2c9d3d5 100644
--- a/src/Caffeine/Resources/en.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/en.lproj/Localizable.strings
@@ -43,3 +43,16 @@
/* System messages */
"Caffeine prevents sleep" = "Caffeine prevents sleep";
+
+/* Protected lid mode */
+"Protected lid mode requires a Mac laptop." = "Protected lid mode requires a Mac laptop.";
+"The macOS lock service is unavailable. Protected lid mode was not enabled." = "The macOS lock service is unavailable. Protected lid mode was not enabled.";
+"Protected lid mode stopped because the battery is low or the Mac is too warm." = "Protected lid mode stopped because the battery is low or the Mac is too warm.";
+"Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again." = "Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again.";
+"Protected lid mode" = "Protected lid mode";
+"Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode." = "Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode.";
+"Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep." = "Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep.";
+"Preparing protected lid mode…" = "Preparing protected lid mode…";
+"Protected lid mode is active." = "Protected lid mode is active.";
+"Stop protected lid mode" = "Stop protected lid mode";
+"Lock & keep awake with lid closed…" = "Lock & keep awake with lid closed…";
diff --git a/src/Caffeine/Resources/protected-lid-watchdog.sh b/src/Caffeine/Resources/protected-lid-watchdog.sh
new file mode 100644
index 0000000..81d4a91
--- /dev/null
+++ b/src/Caffeine/Resources/protected-lid-watchdog.sh
@@ -0,0 +1,110 @@
+#!/bin/sh
+# Installed root-owned by Caffeine after a one-time macOS administrator prompt.
+# launchd runs this fixed script. Client input is data, never executable code.
+set -eu
+PATH=/usr/bin:/bin:/usr/sbin:/sbin
+export PATH
+owner_uid=$1
+case "$owner_uid" in ''|*[!0-9]*) exit 2 ;; esac
+state='/Library/Application Support/CaffeineLid'
+lease="$state/request/lease"
+umask 022
+changed=0
+nonce=''
+restore() {
+ if [ "$changed" = 1 ] || [ -f "$state/owned" ]; then
+ until /usr/bin/pmset -a disablesleep 0; do /bin/sleep 2; done
+ rm -f "$state/owned"
+ fi
+ changed=0
+}
+cleanup() {
+ trap '' HUP INT TERM
+ restore
+ printf 'offline' > "$state/status"
+}
+trap cleanup EXIT
+trap 'exit 0' HUP INT TERM
+# A durable ownership marker allows recovery after the helper is killed or the
+# machine restarts. Never restore a power override that this helper didn't own.
+restore
+printf '%s' "$owner_uid" > "$state/owner"
+printf '1' > "$state/version"
+printf 'idle' > "$state/status"
+phase=idle
+last_nonce=''
+while :; do
+ value=''
+ if [ -f "$lease" ] && [ ! -L "$lease" ]; then
+ # Bounded read: a client cannot make the root process load a huge file.
+ value=$(/bin/dd if="$lease" bs=128 count=1 2>/dev/null) || value=''
+ fi
+ kind=${value%%:*}
+ rest=${value#*:}
+ pid=${rest%%:*}
+ incoming_nonce=${rest#*:}
+ valid=1
+ case "$kind" in pending|locked) ;; *) valid=0 ;; esac
+ case "$pid" in ''|*[!0-9]*) valid=0 ;; esac
+ case "$incoming_nonce" in ''|*[!A-Fa-f0-9-]*) valid=0 ;; esac
+ [ "${#incoming_nonce}" = 36 ] || valid=0
+ now=$(/bin/date +%s)
+ modified=$(/usr/bin/stat -f %m "$lease" 2>/dev/null) || modified=0
+ if [ "$((now - modified))" -gt 10 ] || [ "$modified" -gt "$now" ]; then valid=0; fi
+ if [ "$valid" = 1 ]; then
+ user=$(/bin/ps -p "$pid" -o uid= | /usr/bin/tr -d ' ') || user=''
+ [ "$user" = "$owner_uid" ] || valid=0
+ fi
+ if [ "$phase" != idle ]; then
+ identity_now=$(/bin/ps -p "$session_pid" -o lstart=) || identity_now=''
+ if [ "$valid" = 0 ] || [ "$incoming_nonce" != "$nonce" ] ||
+ [ "$pid" != "$session_pid" ] || [ "$identity_now" != "$identity" ] ||
+ { [ "$phase" = active ] && [ "$kind" != locked ]; } ||
+ { [ "$phase" = ready ] && [ "$((now - started))" -gt 30 ]; }; then
+ restore
+ phase=idle
+ printf 'idle' > "$state/status"
+ fi
+ fi
+ if [ "$phase" = idle ] && [ "$valid" = 1 ] &&
+ [ "$kind" = pending ] && [ "$incoming_nonce" != "$last_nonce" ]; then
+ nonce=$incoming_nonce
+ last_nonce=$nonce
+ original=$(/usr/bin/pmset -g | /usr/bin/awk '$1 == "SleepDisabled" {print $2}') || original=''
+ if [ "$original" = 0 ]; then
+ session_pid=$pid
+ identity=$(/bin/ps -p "$pid" -o lstart=) || identity=''
+ started=$now
+ phase=ready
+ printf 'ready:%s' "$nonce" > "$state/status"
+ else
+ printf 'conflict:%s' "$nonce" > "$state/status"
+ fi
+ elif [ "$phase" = ready ] && [ "$valid" = 1 ] && [ "$kind" = locked ]; then
+ # Recheck after the lock handshake: another app may have changed the
+ # global setting while the client was preparing its lock screen.
+ before=$(/usr/bin/pmset -g | /usr/bin/awk '$1 == "SleepDisabled" {print $2}') || before=''
+ if [ "$before" != 0 ]; then
+ phase=idle
+ printf 'conflict:%s' "$nonce" > "$state/status"
+ /bin/sleep 1
+ continue
+ fi
+ # Ownership is recorded BEFORE changing power, for crash recovery.
+ printf '1' > "$state/owned"
+ changed=1
+ current=''
+ if /usr/bin/pmset -a disablesleep 1; then
+ current=$(/usr/bin/pmset -g | /usr/bin/awk '$1 == "SleepDisabled" {print $2}') || current=''
+ fi
+ if [ "$current" = 1 ]; then
+ phase=active
+ printf 'active:%s' "$nonce" > "$state/status"
+ else
+ restore
+ phase=idle
+ printf 'failed:%s' "$nonce" > "$state/status"
+ fi
+ fi
+ /bin/sleep 1
+done
diff --git a/tests/test_lid_watchdog.py b/tests/test_lid_watchdog.py
new file mode 100644
index 0000000..832999f
--- /dev/null
+++ b/tests/test_lid_watchdog.py
@@ -0,0 +1,222 @@
+"""Run the actual watchdog with a fake pmset and isolated state directory.
+
+No administrator access, power changes, screen locking, or installed helpers.
+"""
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+import time
+import unittest
+
+SOURCE = Path(__file__).resolve().parents[1] / "src/Caffeine/Resources/protected-lid-watchdog.sh"
+NONCE = "12345678-1234-1234-1234-123456789ABC"
+
+
+class WatchdogTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory(prefix="caffeine test '")
+ self.root = Path(self.temp.name)
+ self.state = self.root / "state"
+ self.state.mkdir()
+ (self.state / "request").mkdir()
+ self.lease = self.state / "request/lease"
+ self.power = self.root / "power"
+ self.power.write_text("0")
+ self.calls = self.root / "calls"
+ self.calls.touch()
+ self.failure = self.root / "fail-enable"
+ self.restore_failure = self.root / "fail-restore-once"
+ self.mock = self.root / "pmset"
+ # Quote paths independently of the code under test, including apostrophes.
+ import shlex
+ q = shlex.quote
+ self.mock.write_text(f'''#!/bin/sh
+if [ "$1" = -g ]; then
+ printf 'System-wide power settings:\\n SleepDisabled %s\\n' "$(cat {q(str(self.power))})"
+else
+ echo "$*" >> {q(str(self.calls))}
+ if [ "$3" = 1 ] && [ -f {q(str(self.failure))} ]; then exit 1; fi
+ if [ "$3" = 0 ] && [ -f {q(str(self.restore_failure))} ]; then
+ rm {q(str(self.restore_failure))}; exit 1
+ fi
+ echo "$3" > {q(str(self.power))}
+fi
+''')
+ self.mock.chmod(0o700)
+ # Production has fixed absolute paths; only this isolated test copy uses mocks.
+ self.script = SOURCE.read_text().replace(
+ "state='/Library/Application Support/CaffeineLid'", "state=" + q(str(self.state))
+ ).replace("/usr/bin/pmset", q(str(self.mock)))
+ self.processes = []
+
+ def tearDown(self):
+ self.lease.unlink(missing_ok=True)
+ for process in self.processes:
+ if process.poll() is None:
+ process.terminate()
+ process.wait(timeout=8)
+ self.temp.cleanup()
+
+ def launch(self, value="pending", pid=None, nonce=NONCE):
+ self.client_pid = pid or os.getpid()
+ process = subprocess.Popen(
+ ["/bin/sh", "-c", self.script, "--", str(os.getuid())],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ self.processes.append(process)
+ self.wait_for(lambda: self.status() == "idle")
+ self.lease.write_text(f"{value}:{self.client_pid}:{nonce}")
+ return process
+
+ def wait_for(self, predicate):
+ deadline = time.monotonic() + 7
+ while time.monotonic() < deadline:
+ if predicate():
+ return
+ time.sleep(0.05)
+ self.fail("watchdog did not reach expected state")
+
+ def status(self):
+ try:
+ return (self.state / "status").read_text()
+ except FileNotFoundError:
+ return None
+
+ def active(self):
+ process = self.launch()
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.lease.write_text(f"locked:{self.client_pid}:{NONCE}")
+ self.wait_for(lambda: self.status() == f"active:{NONCE}")
+ self.assertEqual(self.power.read_text().strip(), "1")
+ return process
+
+ def assert_restored(self, process):
+ self.wait_for(lambda: self.status() in ("idle", "offline") and self.power.read_text().strip() == "0")
+ self.assertFalse((self.state / "owned").exists())
+
+ def test_pending_does_not_change_power(self):
+ process = self.launch()
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.assertEqual(self.calls.read_text(), "")
+ self.lease.unlink()
+ self.assert_restored(process)
+
+ def test_confirmed_lock_then_stop_restores_sleep(self):
+ process = self.active()
+ self.lease.unlink()
+ self.assert_restored(process)
+ self.assertEqual(self.calls.read_text().splitlines(), ["-a disablesleep 1", "-a disablesleep 0"])
+
+ def test_expired_heartbeat_restores_sleep(self):
+ process = self.active()
+ old = time.time() - 60
+ os.utime(self.lease, (old, old))
+ self.assert_restored(process)
+
+ def test_unlock_cannot_leave_override_active(self):
+ process = self.active()
+ self.lease.write_text(f"pending:{self.client_pid}:{NONCE}")
+ self.assert_restored(process)
+
+ def test_wrong_nonce_restores_sleep(self):
+ process = self.active()
+ self.lease.write_text("locked:DIFFERENT")
+ self.assert_restored(process)
+
+ def test_existing_override_is_not_overwritten(self):
+ self.power.write_text("1")
+ process = self.launch()
+ self.wait_for(lambda: self.status() == f"conflict:{NONCE}")
+ self.assertEqual(self.calls.read_text(), "")
+ self.assertEqual(self.power.read_text(), "1")
+ process.terminate()
+ process.wait(timeout=8)
+ self.assertEqual(self.power.read_text(), "1")
+
+ def test_override_during_lock_preparation_is_preserved(self):
+ self.launch()
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.power.write_text("1")
+ self.lease.write_text(f"locked:{self.client_pid}:{NONCE}")
+ self.wait_for(lambda: self.status() == f"conflict:{NONCE}")
+ self.assertEqual(self.calls.read_text(), "")
+ self.assertFalse((self.state / "owned").exists())
+
+ def test_second_nonce_ends_old_session_before_new_one(self):
+ process = self.active()
+ second = "AAAAAAAA-1234-1234-1234-123456789ABC"
+ self.lease.write_text(f"pending:{self.client_pid}:{second}")
+ self.wait_for(lambda: self.status() == f"ready:{second}")
+ self.assertEqual(self.power.read_text().strip(), "0")
+ self.lease.unlink()
+ self.assert_restored(process)
+
+ def test_failed_enable_rolls_back(self):
+ self.failure.touch()
+ process = self.launch()
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.lease.write_text(f"locked:{self.client_pid}:{NONCE}")
+ self.wait_for(lambda: self.status() == f"failed:{NONCE}")
+ self.assertEqual(self.power.read_text().strip(), "0")
+ self.assertIn("-a disablesleep 0", self.calls.read_text())
+
+ def test_failed_restore_is_retried(self):
+ process = self.active()
+ self.restore_failure.touch()
+ self.lease.unlink()
+ self.assert_restored(process)
+ self.assertEqual(self.calls.read_text().count("-a disablesleep 0"), 2)
+
+ def test_termination_restores_sleep(self):
+ process = self.active()
+ process.terminate()
+ self.assert_restored(process)
+
+ def test_app_crash_restores_sleep(self):
+ app = subprocess.Popen(["/bin/sleep", "60"])
+ self.processes.append(app)
+ process = self.launch(pid=app.pid)
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.lease.write_text(f"locked:{app.pid}:{NONCE}")
+ self.wait_for(lambda: self.status() == f"active:{NONCE}")
+ app.kill()
+ app.wait()
+ self.assert_restored(process)
+
+ def test_stale_lease_never_enables_sleep_override(self):
+ process = self.launch()
+ self.wait_for(lambda: self.status() == f"ready:{NONCE}")
+ self.lease.write_text(f"locked:{self.client_pid}:{NONCE}")
+ old = time.time() - 60
+ os.utime(self.lease, (old, old))
+ self.assert_restored(process)
+ self.assertEqual(self.calls.read_text(), "")
+
+ def test_invalid_pid_is_never_executed(self):
+ self.launch(pid="1; touch /tmp/should-not-exist")
+ time.sleep(1.5)
+ self.assertEqual(self.status(), "idle")
+ self.assertEqual(self.calls.read_text(), "")
+
+ def test_helper_crash_recovers_durable_override(self):
+ process = self.active()
+ process.kill()
+ process.wait()
+ self.assertTrue((self.state / "owned").exists())
+ self.lease.unlink()
+ restarted = self.launch()
+ self.wait_for(lambda: self.power.read_text().strip() == "0")
+ self.assertFalse((self.state / "owned").exists())
+ self.lease.unlink()
+ self.assert_restored(restarted)
+
+ def test_locked_request_without_handshake_is_rejected(self):
+ self.launch("locked")
+ time.sleep(1.5)
+ self.assertEqual(self.status(), "idle")
+ self.assertEqual(self.calls.read_text(), "")
+
+
+if __name__ == "__main__":
+ unittest.main()
From def060d99dd38ddf1e63110103136866b6ef8c0a Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Sun, 6 Sep 2026 05:48:21 +0300
Subject: [PATCH 2/6] Automatically dim display and keyboard on lid close and
lock on reopen
---
CHANGELOG.md | 13 +-
README.md | 10 +-
docs/PROTECTED-LID.md | 212 ++++++++-------
.../Classes/Models/ActivitySimulator.swift | 1 +
...anager.swift => AutomaticLidManager.swift} | 249 +++++++++++-------
.../Classes/Models/BuiltInDisplay.swift | 56 ++++
.../Classes/Models/KeyboardBacklight.swift | 58 ++++
.../Classes/Models/LidCycleState.swift | 23 ++
src/Caffeine/Classes/Models/LidMonitor.swift | 62 +++++
.../Models/SleepPreventionManager.swift | 111 +-------
.../ViewModels/CaffeineViewModel.swift | 39 +--
.../Classes/Views/AutomaticLidControls.swift | 38 +++
.../Classes/Views/MenuBarController.swift | 20 +-
.../Classes/Views/PreferencesView.swift | 4 +-
.../Classes/Views/ProtectedLidControls.swift | 40 ---
.../Resources/ar.lproj/Localizable.strings | 24 +-
.../Resources/en.lproj/Localizable.strings | 27 +-
tests/LidCycleTests.swift | 30 +++
18 files changed, 602 insertions(+), 415 deletions(-)
rename src/Caffeine/Classes/Models/{ProtectedLidManager.swift => AutomaticLidManager.swift} (53%)
create mode 100644 src/Caffeine/Classes/Models/BuiltInDisplay.swift
create mode 100644 src/Caffeine/Classes/Models/KeyboardBacklight.swift
create mode 100644 src/Caffeine/Classes/Models/LidCycleState.swift
create mode 100644 src/Caffeine/Classes/Models/LidMonitor.swift
create mode 100644 src/Caffeine/Classes/Views/AutomaticLidControls.swift
delete mode 100644 src/Caffeine/Classes/Views/ProtectedLidControls.swift
create mode 100644 tests/LidCycleTests.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bdf0476..f1b413c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,20 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Experimental protected lid sessions: lock macOS first, keep background work running with the lid closed, and request display sleep. Unlocking ends the protected session.
-- First-use administrator installation of a limited persistent helper; later sessions do not repeat the administrator prompt.
-- Short heartbeat lease, client/helper crash recovery, timer/quit cleanup, and low-battery/thermal stopping.
-- Arabic interface, protected-mode controls, isolated watchdog tests, universal build script, helper removal, and hardware validation instructions.
-
+- Automatic lid handling on the ordinary Caffeine toggle: darken the built-in display and keyboard without locking on closure, lock on reopening, then restore the saved brightness levels.
+- First-use administrator helper installation, reused across activations. The existing version-1 helper also supports the new automatic behavior.
+- Arabic interface, universal build and helper removal scripts, heartbeat/crash recovery, battery/thermal stopping, and isolated helper/transition tests.
### Changed
-- Disabled upstream automatic updates for this separately identified direct-distribution fork.
-
+- Replaced the earlier lock-at-activation session button with automatic behavior. Activation no longer locks; unlocking no longer ends Caffeine.
+- Disabled upstream automatic updates for the separately identified direct-distribution fork.
- Improved Ukrainian translation.
### Fixed
+- Removed the two-second gap between sleep-prevention assertions, and suppressed simulated mouse activity while the lid is closed.
- Timer no longer stays active and shows negative seconds after the Mac sleeps past the activation period.
## [1.6.3] - 2026-01-26
diff --git a/README.md b/README.md
index 6172c55..be413d9 100755
--- a/README.md
+++ b/README.md
@@ -1,12 +1,12 @@
-# Caffeine Lid — قفل الجهاز والعمل والغطاء مغلق
+# Caffeine Lid — إضاءة مطفأة عند الإغلاق، وقفل عند الفتح
-Fork of [domzilla/Caffeine](https://github.com/domzilla/Caffeine) with an experimental protected lid session for macOS.
+Fork of [domzilla/Caffeine](https://github.com/domzilla/Caffeine) with automatic lid behavior tied to the normal Caffeine toggle.
-**العربية:** من الإعدادات اختر «قفل الجهاز والعمل والغطاء مغلق…». يُطلب إذن المسؤول مرة واحدة لتثبيت المساعد. يقفل جهازك فورًا ثم يبقي المهام تعمل مع إطفاء الشاشات، ويمكنك إغلاق الغطاء. عند العودة يلزم فتح قفل حساب Mac، وتنتهي جلسة الغطاء المحمي بعد فتح القفل. يلزم اختبار الغطاء والطاقة فعليًا على جهازك؛ هذه نسخة تجريبية وليست إصدارًا موثقًا من Apple.
+**العربية:** فعّل Caffeine من أيقونة الفنجان. عند إغلاق الغطاء ينخفض سطوع الشاشة وإضاءة الكيبورد إلى صفر مع بقاء الجهاز يعمل دون طلب قفله. عند فتح الغطاء يُقفل حساب Mac تلقائيًا، ثم تعود الإضاءة السابقة. يبقى Caffeine مفعّلًا بعد فتح القفل ويكرر السلوك تلقائيًا. صلاحية المسؤول مطلوبة عند تثبيت المساعد أول مرة فقط. يلزم اختبار السلوك على الجهاز فعليًا.
-**English:** A dedicated action locks macOS before keeping work running with the lid closed and displays off. Administrator approval is needed only for the first helper installation. Unlocking ends the protected session. Hardware verification is still required.
+**English:** Activate Caffeine normally. Closing the lid dims the built-in display and keyboard to zero while keeping the Mac awake without requesting a lock. Opening requests the native lock, then restores brightness after confirmation. Unlocking keeps Caffeine active for future cycles. Administrator approval is needed only for the helper's initial installation.
-Build with `bash scripts/build-lid.sh`. Read [usage, helper removal, recovery, and validation](docs/PROTECTED-LID.md) before testing.
+Build with `bash scripts/build-lid.sh`. See [behavior, recovery and validation](docs/PROTECTED-LID.md).
---
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
index 314bcba..f62b8f6 100644
--- a/docs/PROTECTED-LID.md
+++ b/docs/PROTECTED-LID.md
@@ -1,126 +1,138 @@
-# Protected lid sessions
-
-This fork adds an **experimental direct-distribution** feature for Mac laptops.
-The ordinary coffee-cup toggle retains its existing behavior. The separate
-**Lock & keep awake with lid closed…** action starts a protected session.
-
-## Use
-
-1. Build with `bash scripts/build-lid.sh` and open `dist/Caffeine Lid.app`.
- It uses a separate bundle identifier and preferences from upstream Caffeine.
- Quit the original Caffeine while using this fork: another display assertion
- or simulated mouse activity can interfere with turning the displays off.
-2. Open preferences from the menu bar, choose a duration if desired, and select
- **Lock & keep awake with lid closed…**.
-3. **The first session only** asks macOS to install a small privileged helper.
- Authenticate in the native macOS administrator dialog. Caffeine never reads,
- stores, or creates a password. Canceling leaves lid sleep unchanged.
-4. The Mac locks immediately. After the lock is confirmed, the helper disables
- system sleep and Caffeine requests display sleep. Close the lid when the
- lock screen/display turns off. Background work can continue on battery or AC.
-5. Opening the lid reveals the native lock screen. Use the account password or
- the authentication methods permitted by macOS, such as Touch ID.
- **Unlocking ends the protected session** and resumes ordinary Caffeine.
- Start a new protected session before closing the lid again.
-
-Locking before enabling the override avoids relying on a notification after the
-display has already become visible. This mode intentionally locks immediately;
-it does not let you keep editing with an unlocked screen until lid closure.
-It turns off external displays too. It does not shut down the Mac or log out.
-
-## Lifetime and recovery
-
-- Caffeine holds a short lease that it renews only while the screen is confirmed
- locked. Stop, Quit, timer expiry, unlock, or failure removes that lease.
-- The helper restores normal sleep on its next poll, normally within one second.
- If Caffeine hangs, the lease expires after ten seconds. PID, UID, process start
- time, a fresh UUID, and a pending-to-locked handshake guard against stale requests.
-- launchd restarts the helper after failure. A root-owned durable ownership marker
- triggers restoration after a helper crash or reboot, before accepting a session.
-- A pre-existing `SleepDisabled` override is treated as a conflict and left alone.
- Avoid running other tools that change the same global setting during a session;
- macOS does not provide independent ownership of that setting for each app.
-- The app ends the protected session at 10% battery on battery power, or on a
- serious/critical thermal state. Hardware protection and shutdown cannot and
- should not be overridden. Keep ventilation clear; do not run it in a closed bag.
-
-## Helper scope
-
-The first-use installer writes these fixed locations with administrator approval:
+# Automatic lid control
+
+This direct-distribution fork couples lid behavior to the normal Caffeine toggle.
+There is no separate protected-session button and activation never requests a lock.
+
+## Behavior
+
+| Event while Caffeine is active | Result |
+| --- | --- |
+| Activate with the lid open | Arm monitoring and prevent system sleep; stay unlocked |
+| Close the lid | Set built-in screen brightness and keyboard backlight to zero; remain awake without requesting a lock |
+| Open the lid | Request the native macOS lock immediately, then restore both saved brightness levels after lock confirmation |
+| Authenticate | Caffeine remains active; the next lid cycle works automatically |
+| Deactivate, timer expires, or quit | Release the helper lease and restore ordinary sleep/brightness |
+
+The user's existing Mac login credentials apply; Caffeine never creates, reads,
+or stores a password. macOS may allow Touch ID or other configured authentication.
+Manual locking and managed security policies are not overridden or undone.
+
+## Installation and usage
+
+Build with `bash scripts/build-lid.sh`, open `dist/Caffeine Lid.app`, and activate
+Caffeine using the menu-bar cup or **Activate Caffeine** in preferences. Wait for
+**Ready: lights off on close, lock on open** before testing the lid. First activation
+installs a limited helper using the native administrator dialog. Later activations
+need no administrator prompt. The previously installed version-1 helper is reused;
+this behavior change requires no new privileged operation or installation.
+
+Quit other keep-awake/brightness utilities while testing. This fork has its own
+bundle identifier `net.ziyad.caffeine.lid` and does not accept upstream auto-updates.
+The build is universal (Apple Silicon and Intel), targets macOS 14.6+, and is ad-hoc
+signed, not Developer ID signed or notarized. Build outside App Sandbox using the
+provided script; the upstream Xcode project is intentionally unchanged.
+
+## How it works
+
+- A continuous `PreventUserIdleDisplaySleep` assertion avoids the upstream
+ assertion's two-second gap. Screen darkness is brightness zero, not display
+ sleep, so the normal display-sleep/password trigger is not intentionally invoked.
+- `IOPMrootDomain` lid-change notifications run on the main queue; their event
+ bitfield drives close/open transitions directly. A 250 ms poll catches missed
+ notifications. Duplicate events never relock an already open laptop.
+- The built-in display uses `DisplayServicesGetBrightness/SetBrightness`.
+ The built-in keyboard uses `KeyboardBrightnessClient` from CoreBrightness.
+ Current levels are remembered while open and reapplied after confirmed locking.
+ Zero brightness is reasserted once per second while closed. External monitors
+ are not dimmed; native locking still locks the session across all displays.
+- During a closed-lid session, periodic IOKit user-activity assertions postpone
+ idle behavior. Synthetic mouse activity from the optional Keep apps active
+ feature is suppressed while the lid is closed.
+- Opening calls `SACLockScreenImmediate`. Brightness restoration waits for
+ `CGSSessionScreenIsLocked`. If confirmation fails, the screen remains dark;
+ the brightness keys provide manual recovery. No lock is requested on closing.
+
+macOS controls the timing of panel power and lock-screen presentation. This is
+**not a guarantee that zero pixels can ever be visible before locking on every
+Mac or OS version**. Notification/brightness/lock APIs and hardware ordering need
+physical validation. Unlike locking before closure, this requested behavior leaves
+an unlocked session running while the lid is closed. Private Apple interfaces may
+change, and organizational security policy can enforce independent locking.
+
+## Power helper and recovery
+
+The first-use installer writes fixed root-owned locations:
- `/Library/PrivilegedHelperTools/net.ziyad.caffeine.lid-helper.sh`
- `/Library/LaunchDaemons/net.ziyad.caffeine.lid-helper.plist`
- `/Library/Application Support/CaffeineLid/`
-The script, plist, ownership marker, and status are root-owned. A single request
-directory is owned by the installing user's numeric UID with mode 0700. The helper
-accepts bounded data from that user only, validates it, and runs fixed `pmset`
-commands. It never executes a client-supplied command or sources a user-writable
-script. No sudoers exception is installed. Other processes running as that same
-user can request the same limited power operation; this is not code-signature
-authenticated IPC. The helper supports one installing account and one session.
-
-The helper remains installed and idle between sessions, so subsequent sessions
-do not require another administrator prompt. A future helper upgrade or removal
-may require administrator approval. Removing the app alone does not remove the
-helper. After quitting the app, uninstall it with:
+Only the installing UID may write the request directory (mode 0700). Requests are
+bounded data, never commands. The helper validates UID, PID/start identity, UUID,
+freshness and a two-step handshake, then runs fixed `pmset` operations. The wire
+opcode `locked` is the historical version-1 name for **activate the power override**;
+it does not ask the helper to lock the screen or attest to lock state. Keeping that
+wire format permits the one-time-installed helper to serve this updated client.
+No sudoers exception or stored password is used. This IPC authorizes the installing
+user, not a specific code signature; other processes under that UID can request
+this same limited power operation. Only one installing account/session is supported.
+
+The helper normally restores sleep within one poll (about one second) after lease
+removal. A stalled client loses its lease after ten seconds. launchd restarts the
+helper after a crash; a durable ownership marker restores this helper's override
+before accepting new sessions, including after a reboot. An external pre-existing
+SleepDisabled override is preserved and reported as a conflict. Avoid other apps
+that change the same global setting mid-session, as macOS exposes no per-app ownership.
+
+The app stops at 10% battery while unplugged or a serious/critical thermal state.
+Brightness zero reduces lighting power; the running CPU still consumes more energy
+than sleep. Do not run the laptop in a closed bag; keep ventilation clear.
+
+After quitting Caffeine, remove the persistent helper with:
```sh
sudo /bin/sh scripts/uninstall-lid-helper.sh
```
-If powerd or launchd cannot restore sleep, recovery is:
+Emergency power recovery, if launchd/powerd is unavailable:
```sh
sudo pmset -a disablesleep 0
```
-## Build, tests, and limitations
+The helper is deliberately idle between sessions and is not removed by deleting
+the application alone. A future helper upgrade/removal can require administrator
+approval; ordinary toggling does not.
-The build script preserves the upstream Xcode project and overrides its sandbox
-and bundle identifier at build time. The privileged feature requires a build
-outside App Sandbox. Upstream automatic updates are disabled so they cannot
-replace this fork. Builds are ad-hoc signed, not Developer ID signed or notarized.
-They target macOS 14.6 or newer and include Apple Silicon and Intel code.
+## Validation
```sh
python3 -m unittest discover -s tests -v
+swiftc src/Caffeine/Classes/Models/LidCycleState.swift tests/LidCycleTests.swift -o /tmp/caffeine-lid-tests
+/tmp/caffeine-lid-tests
bash scripts/build-lid.sh
swiftformat .
```
-New UI is localized in English and Arabic; other existing languages fall back to
-English for the new strings. Upstream developer guides referenced by AGENTS.md
-are not included in the repository; existing style and SwiftFormat are used.
-
-Automated tests run the actual watchdog with an isolated fake power backend;
-they never change real power settings, install the helper, or lock the computer.
-They cover confirmed-lock gating, stale leases, client and helper crashes,
-termination, conflicting overrides, invalid input, and failed enable/restore.
-
-Physical lid closure, panel darkness, uninterrupted work on battery/AC,
-authentication on reopening, the first administrator prompt and subsequent
-prompt-free sessions still require manual hardware validation. The lock operation
-uses the private macOS `SACLockScreenImmediate` symbol and confirmation uses
-`CGSSessionScreenIsLocked`; either can change in macOS updates. Missing lock
-support or confirmation aborts the session. Successful compilation and mocked
-tests do not establish hardware compatibility or a production security guarantee.
-
-### Manual acceptance checklist
-
-- Cancel first-use authorization: no helper and no power override.
-- Approve once, stop, start again: no second administrator prompt.
-- Start a background timestamp log, activate, close the lid on battery and AC:
- timestamps continue and the built-in panel is off.
-- Open the lid: no desktop is exposed before authentication; unlock ends mode.
-- Stop, timeout, quit, force-quit, and stop client heartbeat: `pmset -g` returns
- `SleepDisabled 0` within the documented window.
-- Kill the helper during a session: launchd restarts it and restores the override.
-- Reboot during a session: recovery runs, and no protected session starts itself.
-- Check low battery, thermal stop, an existing external override, and uninstall.
+The existing 16 isolated watchdog tests cover heartbeat expiration, client/helper
+crashes, failed enable/restore, conflicts and untrusted request data. The transition
+suite covers activation without locking, close without locking, open with locking,
+confirmation-before-brightness restoration, repeated events, successive cycles and
+rapid reclosure while locking. Neither suite closes the physical lid or locks the
+user's actual session. Read-only probes confirmed display-brightness and keyboard
+backlight interfaces are available on the development Mac.
+
+Manual acceptance requires closing/opening the lid on battery and AC, observing
+both lights go off while a background job continues, verifying authentication is
+required on reopening, and repeating the cycle after unlocking. Also verify that
+an extended closed-lid session does not trigger independent idle locking and that
+the former brightness levels return. Hardware checks cannot be substituted by a
+successful build. New strings are localized in Arabic and English; other languages
+fall back to English for this feature.
## References
-- [Apple PowerManagement implementation of pmset](https://github.com/apple-oss-distributions/PowerManagement/blob/main/pmset/pmset.m)
-- [Apple's IOPM definitions, including lid state](https://github.com/apple-oss-distributions/IOKitUser/blob/main/pwr_mgt.subproj/IOPM.h)
-- [Lock function and lock-state example](https://gist.github.com/pudquick/9797a9ce8ad97de6e326afc7c9894965)
+- [Apple pmset implementation](https://github.com/apple-oss-distributions/PowerManagement/blob/main/pmset/pmset.m)
+- [Apple IOPM API](https://github.com/opensource-apple/IOKitUser/blob/master/pwr_mgt.subproj/IOPMLib.h)
+- [DisplayServices brightness implementation example](https://github.com/nriley/brightness/blob/master/brightness.c)
+- [KeyboardBrightnessClient method signatures](https://github.com/rakalex/mac-brightnessctl/blob/master/KeyboardBrightnessClient.h)
diff --git a/src/Caffeine/Classes/Models/ActivitySimulator.swift b/src/Caffeine/Classes/Models/ActivitySimulator.swift
index 1c87c91..69a229d 100644
--- a/src/Caffeine/Classes/Models/ActivitySimulator.swift
+++ b/src/Caffeine/Classes/Models/ActivitySimulator.swift
@@ -55,6 +55,7 @@ final class ActivitySimulator {
// MARK: - Private Methods
private func checkAndSimulateIfNeeded() {
+ guard LidMonitor.readClosed() != true else { return }
guard self.getSystemIdleTime() >= self.idleThreshold else { return }
self.simulateActivity()
}
diff --git a/src/Caffeine/Classes/Models/ProtectedLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
similarity index 53%
rename from src/Caffeine/Classes/Models/ProtectedLidManager.swift
rename to src/Caffeine/Classes/Models/AutomaticLidManager.swift
index 7773872..12b9944 100644
--- a/src/Caffeine/Classes/Models/ProtectedLidManager.swift
+++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
@@ -2,19 +2,28 @@ import AppKit
import Combine
import IOKit
import IOKit.ps
+import IOKit.pwr_mgt
-/// A protected session is locked BEFORE lid sleep is disabled. It ends on
-/// authentication, so opening the lid can never expose an unlocked desktop.
+/// Watches every lid cycle while Caffeine is active. Closing only darkens the
+/// built-in panel. Opening requests the native lock before restoring brightness.
@MainActor
-final class ProtectedLidManager: ObservableObject {
+final class AutomaticLidManager: ObservableObject {
@Published private(set) var isPreparing = false
@Published private(set) var isRunning = false
+ @Published private(set) var isLidClosed = false
@Published var errorMessage: String?
- var didStop: (() -> Void)?
+ var didFail: (() -> Void)?
private var sessionID: UUID?
private var leaseURL: URL?
private var monitor: Task?
+ private var lockTask: Task?
+ private var cycle = LidCycleState()
+ private let display = BuiltInDisplay()
+ private let keyboard = KeyboardBacklight()
+ private var lidMonitor: LidMonitor?
+ private var activityID: IOPMAssertionID = 0
+ private var lastActivity = Date.distantPast
private let loginLibrary = dlopen("/System/Library/PrivateFrameworks/login.framework/login", RTLD_LAZY)
private let helperDirectory = URL(fileURLWithPath: "/Library/Application Support/CaffeineLid", isDirectory: true)
private var statusURL: URL {
@@ -28,106 +37,102 @@ final class ProtectedLidManager: ObservableObject {
func start() async {
guard !self.isEngaged else { return }
self.errorMessage = nil
- guard Self.lidIsClosed() != nil else {
- self.errorMessage = String(localized: "Protected lid mode requires a Mac laptop.")
- self.didStop?()
- return
- }
guard
- let library = self.loginLibrary,
- let symbol = dlsym(library, "SACLockScreenImmediate") else
+ let closed = LidMonitor.readClosed(), self.lockFunction != nil,
+ self.display.prepare() else
{
+ self.fail(String(localized: "Automatic lid control is unavailable on this Mac. Caffeine was deactivated."))
+ return
+ }
+ if !self.keyboard.prepare() {
self
.errorMessage =
- String(localized: "The macOS lock service is unavailable. Protected lid mode was not enabled.")
- self.didStop?()
- return
+ String(
+ localized: "Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work."
+ )
}
let id = UUID()
self.sessionID = id
self.isPreparing = true
+ // Monitor before enabling the override: no close/open transition during
+ // administrator approval or helper startup is silently discarded.
+ self.cycle = LidCycleState()
+ let watcher = LidMonitor { [weak self] closed in self?.handleLid(closed) }
+ guard watcher.start() else {
+ self.fail(String(localized: "Automatic lid control is unavailable on this Mac. Caffeine was deactivated."))
+ return
+ }
+ self.lidMonitor = watcher
+ self.handleLid(closed)
do {
if !self.helperIsInstalled() {
guard let scriptURL = Bundle.main.url(forResource: "protected-lid-watchdog", withExtension: "sh") else {
throw LidError.unavailable
}
- let script = try String(contentsOf: scriptURL, encoding: .utf8)
- try await Self.authorize(Self.installCommand(script: script))
+ try await Self.authorize(Self.installCommand(script: String(contentsOf: scriptURL, encoding: .utf8)))
}
guard self.sessionID == id else { return }
- // An existing session belongs to another running copy of Caffeine.
for _ in 0..<50 {
- let status = self.readStatus()
- if self.helperIsInstalled(), status != nil, status != "offline" {
+ if self.helperIsInstalled(), let status = self.readStatus(), status != "offline" {
break
}
try await Task.sleep(for: .milliseconds(100))
}
guard
- self.helperIsInstalled(),
- let status = self.readStatus(), status != "offline",
- !status.hasPrefix("active:"), !status.hasPrefix("ready:") else
- {
- throw LidError.unavailable
- }
+ self.helperIsInstalled(), let status = self.readStatus(), status != "offline",
+ !status.hasPrefix("active:"), !status.hasPrefix("ready:") else { throw LidError.unavailable }
self.leaseURL = self.helperDirectory.appendingPathComponent("request/lease")
- try self.refreshLease(locked: false)
+ try self.refreshLease(allowOverride: false)
try await self.waitForStatus("ready", id: id)
- guard self.sessionID == id else { return }
-
- typealias LockFunction = @convention(c) () -> Void
- let lock = unsafeBitCast(symbol, to: LockFunction.self)
- lock()
- var confirmed = false
- for _ in 0..<50 {
- guard self.sessionID == id else { return }
- if Self.isScreenLocked() == true {
- confirmed = true
- break
- }
- try await Task.sleep(for: .milliseconds(100))
- }
- guard confirmed else { throw LidError.unavailable }
- try self.refreshLease(locked: true)
+ // The helper only manages power. No lock is requested at activation.
+ try self.refreshLease(allowOverride: true)
try await self.waitForStatus("active", id: id)
guard self.sessionID == id else { return }
- guard Self.isScreenLocked() == true else { throw LidError.unavailable }
self.isPreparing = false
self.isRunning = true
- self.sleepDisplays()
self.monitor = Task { [weak self] in
- var wasClosed = Self.lidIsClosed()
+ var heartbeat = Date.distantPast
while !Task.isCancelled {
- do { try await Task.sleep(for: .seconds(1)) } catch { return }
+ do { try await Task.sleep(for: .milliseconds(250)) } catch { return }
guard let self, self.sessionID == id else { return }
- // Unknown lock state fails closed: stop renewing the lease.
- guard Self.isScreenLocked() == true else { self.stop()
- return
- }
- guard self.readStatus() == "active:\(id.uuidString)" else {
- self.fail()
- return
- }
- guard !Self.shouldStopForPower() else {
- self.stop()
- self
- .errorMessage =
- String(
- localized: "Protected lid mode stopped because the battery is low or the Mac is too warm."
- )
- return
- }
- do { try self.refreshLease(locked: true) } catch { self.fail()
- return
- }
- let closed = Self.lidIsClosed()
- guard closed != nil else { self.fail()
+ guard let closed = LidMonitor.readClosed() else { self.fail()
return
}
- if closed == true, wasClosed != true {
- self.sleepDisplays()
+ self.handleLid(closed) // Fallback for dropped IOKit notifications.
+ if Date().timeIntervalSince(heartbeat) >= 1 {
+ if closed {
+ // Keep the logical display awake and prevent idle lock;
+ // darkness is brightness=0, never display sleep.
+ if Date().timeIntervalSince(self.lastActivity) >= 20, Self.isScreenLocked() != true {
+ IOPMAssertionDeclareUserActivity(
+ "Caffeine closed lid" as CFString,
+ kIOPMUserActiveLocal,
+ &self.activityID
+ )
+ self.lastActivity = Date()
+ }
+ self.darkenLights()
+ } else if !self.cycle.lockPending {
+ self.display.rememberBrightness()
+ self.keyboard.rememberBrightness()
+ }
+ guard self.readStatus() == "active:\(id.uuidString)" else { self.fail()
+ return
+ }
+ if Self.shouldStopForPower() {
+ self
+ .fail(
+ String(
+ localized: "Caffeine stopped because the battery is low or the Mac is too warm."
+ )
+ )
+ return
+ }
+ do { try self.refreshLease(allowOverride: true) } catch { self.fail()
+ return
+ }
+ heartbeat = Date()
}
- wasClosed = closed
}
}
} catch {
@@ -137,39 +142,100 @@ final class ProtectedLidManager: ObservableObject {
}
func stop() {
- let wasEngaged = self.isEngaged
let oldID = self.sessionID
self.sessionID = nil
self.monitor?.cancel()
self.monitor = nil
+ self.lidMonitor?.stop()
+ self.lidMonitor = nil
if
let leaseURL, let oldID,
- let value = try? String(contentsOf: leaseURL, encoding: .utf8),
- value.hasSuffix(":" + oldID.uuidString)
+ let value = try? String(contentsOf: leaseURL, encoding: .utf8), value.hasSuffix(":" + oldID.uuidString)
{
try? FileManager.default.removeItem(at: leaseURL)
}
self.leaseURL = nil
- self.isRunning = false
self.isPreparing = false
- // Keep the dynamically loaded lock function valid for the app lifetime.
- if wasEngaged {
- self.didStop?()
+ self.isRunning = false
+ if self.activityID != 0 {
+ IOPMAssertionRelease(self.activityID)
+ self.activityID = 0
+ }
+ // A lock already requested on opening must finish before making the
+ // desktop visible, even when a timeout/stop races with that opening.
+ if !self.cycle.lockPending {
+ self.restoreLights()
+ }
+ }
+
+ private func handleLid(_ closed: Bool) {
+ if self.isLidClosed != closed {
+ self.isLidClosed = closed
+ }
+ guard let action = self.cycle.update(closed: closed) else { return }
+ switch action {
+ case .darken:
+ self.darkenLights()
+ case .lock:
+ // Called directly on the main queue by the IOKit callback.
+ self.darkenLights()
+ self.lockFunction?()
+ self.lockTask?.cancel()
+ self.lockTask = Task { [weak self] in
+ for attempt in 0..<100 {
+ guard let self, !Task.isCancelled else { return }
+ if Self.isScreenLocked() == true {
+ if self.cycle.confirmLock() {
+ self.restoreLights()
+ }
+ return
+ }
+ if attempt > 0, attempt % 10 == 0 {
+ self.lockFunction?()
+ }
+ do { try await Task.sleep(for: .milliseconds(100)) } catch { return }
+ }
+ // Do not reveal an unlocked desktop if native locking failed.
+ self?
+ .errorMessage =
+ String(
+ localized: "macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display."
+ )
+ }
}
}
- private func fail() {
+ private func darkenLights() {
+ self.display.darken()
+ self.keyboard.darken()
+ }
+
+ private func restoreLights() {
+ self.display.restore()
+ self.keyboard.restore()
+ }
+
+ private typealias LockFunction = @convention(c) () -> Void
+ private var lockFunction: LockFunction? {
+ guard let library = self.loginLibrary, let symbol = dlsym(library, "SACLockScreenImmediate") else { return nil }
+ return unsafeBitCast(symbol, to: LockFunction.self)
+ }
+
+ private func fail(_ message: String? = nil) {
self.stop()
self
- .errorMessage =
+ .errorMessage = message ??
String(
- localized: "Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again."
+ localized: "Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again."
)
+ self.didFail?()
}
- private func refreshLease(locked: Bool) throws {
+ private func refreshLease(allowOverride: Bool) throws {
guard let leaseURL, let sessionID else { throw LidError.unavailable }
- let value = "\(locked ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(sessionID.uuidString)"
+ // "locked" is the legacy v1 helper's activation opcode. Reusing its
+ // power-only protocol avoids another administrator installation prompt.
+ let value = "\(allowOverride ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(sessionID.uuidString)"
try value.write(to: leaseURL, atomically: true, encoding: .utf8)
}
@@ -188,15 +254,6 @@ final class ProtectedLidManager: ObservableObject {
throw LidError.unavailable
}
- private func sleepDisplays() {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/pmset")
- process.arguments = ["displaysleepnow"]
- process.standardOutput = FileHandle.nullDevice
- process.standardError = FileHandle.nullDevice
- try? process.run()
- }
-
private func helperIsInstalled() -> Bool {
let owner = try? String(contentsOf: self.helperDirectory.appendingPathComponent("owner"), encoding: .utf8)
let version = try? String(contentsOf: self.helperDirectory.appendingPathComponent("version"), encoding: .utf8)
@@ -265,14 +322,6 @@ final class ProtectedLidManager: ObservableObject {
return session["CGSSessionScreenIsLocked"] as? Bool
}
- private static func lidIsClosed() -> Bool? {
- let root = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain"))
- guard root != 0 else { return nil }
- defer { IOObjectRelease(root) }
- return IORegistryEntryCreateCFProperty(root, "AppleClamshellState" as CFString, kCFAllocatorDefault, 0)?
- .takeRetainedValue() as? Bool
- }
-
private static func shouldStopForPower() -> Bool {
if [.serious, .critical].contains(ProcessInfo.processInfo.thermalState) {
return true
diff --git a/src/Caffeine/Classes/Models/BuiltInDisplay.swift b/src/Caffeine/Classes/Models/BuiltInDisplay.swift
new file mode 100644
index 0000000..0e88d86
--- /dev/null
+++ b/src/Caffeine/Classes/Models/BuiltInDisplay.swift
@@ -0,0 +1,56 @@
+import AppKit
+
+@MainActor
+final class BuiltInDisplay {
+ private typealias GetBrightness = @convention(c) (UInt32, UnsafeMutablePointer) -> Int32
+ private typealias SetBrightness = @convention(c) (UInt32, Float) -> Int32
+ private let library = dlopen(
+ "/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices",
+ RTLD_LAZY
+ )
+ private var displayID: CGDirectDisplayID?
+ private var brightness: Float?
+ private var isDarkened = false
+
+ func prepare() -> Bool {
+ guard self.getBrightness != nil, self.setBrightness != nil else { return false }
+ var displays = [CGDirectDisplayID](repeating: 0, count: 16)
+ var count: UInt32 = 0
+ guard CGGetOnlineDisplayList(16, &displays, &count) == .success else { return false }
+ self.displayID = displays.prefix(Int(count)).first { CGDisplayIsBuiltin($0) != 0 }
+ self.rememberBrightness()
+ return self.displayID != nil && self.brightness != nil
+ }
+
+ func rememberBrightness() {
+ guard !self.isDarkened, let displayID, let get = self.getBrightness else { return }
+ var value: Float = 0
+ if get(displayID, &value) == 0, value.isFinite, (0...1).contains(value) {
+ self.brightness = value
+ }
+ }
+
+ @discardableResult
+ func darken() -> Bool {
+ guard let displayID, let set = self.setBrightness else { return false }
+ self.isDarkened = true
+ return set(displayID, 0) == 0
+ }
+
+ func restore() {
+ guard self.isDarkened, let displayID, let brightness, let set = self.setBrightness else { return }
+ if set(displayID, brightness) == 0 {
+ self.isDarkened = false
+ }
+ }
+
+ private var getBrightness: GetBrightness? {
+ guard let library, let symbol = dlsym(library, "DisplayServicesGetBrightness") else { return nil }
+ return unsafeBitCast(symbol, to: GetBrightness.self)
+ }
+
+ private var setBrightness: SetBrightness? {
+ guard let library, let symbol = dlsym(library, "DisplayServicesSetBrightness") else { return nil }
+ return unsafeBitCast(symbol, to: SetBrightness.self)
+ }
+}
diff --git a/src/Caffeine/Classes/Models/KeyboardBacklight.swift b/src/Caffeine/Classes/Models/KeyboardBacklight.swift
new file mode 100644
index 0000000..3344e1c
--- /dev/null
+++ b/src/Caffeine/Classes/Models/KeyboardBacklight.swift
@@ -0,0 +1,58 @@
+import Foundation
+
+@MainActor
+final class KeyboardBacklight {
+ private typealias Read = @convention(c) (AnyObject, Selector, UInt64) -> Float
+ private typealias Write = @convention(c) (AnyObject, Selector, Float, UInt64) -> Bool
+ private typealias IsBuiltIn = @convention(c) (AnyObject, Selector, UInt64) -> Bool
+ private let library = dlopen("/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness", RTLD_LAZY)
+ private var client: NSObject?
+ private var keyboardID: UInt64?
+ private var brightness: Float?
+ private var isDarkened = false
+ private let readSelector = NSSelectorFromString("brightnessForKeyboard:")
+ private let writeSelector = NSSelectorFromString("setBrightness:forKeyboard:")
+
+ func prepare() -> Bool {
+ guard
+ self.library != nil,
+ let type = NSClassFromString("KeyboardBrightnessClient") as? NSObject.Type else { return false }
+ let client = type.init()
+ let idsSelector = NSSelectorFromString("copyKeyboardBacklightIDs")
+ let builtInSelector = NSSelectorFromString("isKeyboardBuiltIn:")
+ guard
+ [idsSelector, builtInSelector, self.readSelector, self.writeSelector]
+ .allSatisfy({ client.responds(to: $0) }),
+ let ids = client.perform(idsSelector)?.takeRetainedValue() as? [NSNumber] else { return false }
+ let builtIn = unsafeBitCast(client.method(for: builtInSelector), to: IsBuiltIn.self)
+ self.keyboardID = ids.first { builtIn(client, builtInSelector, $0.uint64Value) }?.uint64Value
+ self.client = client
+ self.rememberBrightness()
+ return self.keyboardID != nil && self.brightness != nil
+ }
+
+ func rememberBrightness() {
+ guard !self.isDarkened, let client, let keyboardID else { return }
+ let read = unsafeBitCast(client.method(for: self.readSelector), to: Read.self)
+ let value = read(client, self.readSelector, keyboardID)
+ if value.isFinite, (0...1).contains(value) {
+ self.brightness = value
+ }
+ }
+
+ @discardableResult
+ func darken() -> Bool {
+ guard let client, let keyboardID else { return false }
+ self.isDarkened = true
+ let write = unsafeBitCast(client.method(for: self.writeSelector), to: Write.self)
+ return write(client, self.writeSelector, 0, keyboardID)
+ }
+
+ func restore() {
+ guard self.isDarkened, let client, let keyboardID, let brightness else { return }
+ let write = unsafeBitCast(client.method(for: self.writeSelector), to: Write.self)
+ if write(client, self.writeSelector, brightness, keyboardID) {
+ self.isDarkened = false
+ }
+ }
+}
diff --git a/src/Caffeine/Classes/Models/LidCycleState.swift b/src/Caffeine/Classes/Models/LidCycleState.swift
new file mode 100644
index 0000000..3a69f84
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LidCycleState.swift
@@ -0,0 +1,23 @@
+/// Pure transition policy, independently tested without changing real hardware.
+struct LidCycleState {
+ enum Action: Equatable { case darken, lock }
+ private(set) var closed = false
+ private(set) var lockPending = false
+
+ mutating func update(closed: Bool) -> Action? {
+ guard self.closed != closed else { return nil }
+ self.closed = closed
+ if closed {
+ return .darken
+ }
+ self.lockPending = true
+ return .lock
+ }
+
+ /// Brightness may return only after native lock confirmation, with lid open.
+ mutating func confirmLock() -> Bool {
+ guard self.lockPending else { return false }
+ self.lockPending = false
+ return !self.closed
+ }
+}
diff --git a/src/Caffeine/Classes/Models/LidMonitor.swift b/src/Caffeine/Classes/Models/LidMonitor.swift
new file mode 100644
index 0000000..5356bbf
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LidMonitor.swift
@@ -0,0 +1,62 @@
+import Foundation
+import IOKit
+import IOKit.pwr_mgt
+
+@MainActor
+final class LidMonitor {
+ private let changed: (Bool) -> Void
+ private var port: IONotificationPortRef?
+ private var notification: io_object_t = 0
+
+ init(changed: @escaping (Bool) -> Void) {
+ self.changed = changed
+ }
+
+ func start() -> Bool {
+ self.stop()
+ let root = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain"))
+ guard root != 0 else { return false }
+ defer { IOObjectRelease(root) }
+ guard let port = IONotificationPortCreate(kIOMainPortDefault) else { return false }
+ self.port = port
+ IONotificationPortSetDispatchQueue(port, .main)
+ let result = IOServiceAddInterestNotification(
+ port, root, kIOGeneralInterest,
+ { context, _, message, argument in
+ // IOPM.h macro is not imported by Swift: sys_iokit | err_sub(13) | 0x100.
+ let clamshellMessage: UInt32 = (0x38 << 26) | (13 << 14) | 0x100
+ guard message == clamshellMessage, let context else { return }
+ let closed = (UInt(bitPattern: argument) & 1) != 0
+ MainActor.assumeIsolated {
+ let monitor = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ monitor.changed(closed)
+ }
+ },
+ Unmanaged.passUnretained(self).toOpaque(), &self.notification
+ )
+ if result != kIOReturnSuccess {
+ self.stop()
+ return false
+ }
+ return true
+ }
+
+ func stop() {
+ if self.notification != 0 {
+ IOObjectRelease(self.notification)
+ self.notification = 0
+ }
+ if let port {
+ IONotificationPortDestroy(port)
+ self.port = nil
+ }
+ }
+
+ static func readClosed() -> Bool? {
+ let root = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPMrootDomain"))
+ guard root != 0 else { return nil }
+ defer { IOObjectRelease(root) }
+ return IORegistryEntryCreateCFProperty(root, "AppleClamshellState" as CFString, kCFAllocatorDefault, 0)?
+ .takeRetainedValue() as? Bool
+ }
+}
diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift
index 5c6a692..f988f51 100644
--- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift
+++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift
@@ -1,115 +1,32 @@
-//
-// SleepPreventionManager.swift
-// Caffeine
-//
-// Created by Dominic Rodemer on 11.11.25.
-//
-
import AppKit
-import Foundation
import IOKit.pwr_mgt
-/// Manages the core functionality of preventing system sleep
+/// Holds one continuous display-idle assertion for the active Caffeine session.
+/// Closed-lid sleep is handled independently by the privileged lease helper.
final class SleepPreventionManager {
static let shared = SleepPreventionManager()
+ private var assertionID: IOPMAssertionID?
- private var sleepAssertionID: IOPMAssertionID?
- private var assertionTimer: Timer?
- private var isUserSessionActive = true
-
- private init() {
- self.setupWorkspaceNotifications()
- }
-
- deinit {
- releaseSleepAssertion()
- assertionTimer?.invalidate()
- NotificationCenter.default.removeObserver(self)
- }
-
- // MARK: - Public Methods
+ private init() {}
- /// Prevents the system from sleeping
func preventSleep() {
- // Start or restart the assertion timer
- self.assertionTimer?.invalidate()
- self.assertionTimer = Timer.scheduledTimer(
- withTimeInterval: 10.0,
- repeats: true
- ) { [weak self] _ in
- self?.refreshSleepAssertion()
- }
- self.assertionTimer?.fire() // Fire immediately
- }
-
- /// Allows the system to sleep normally
- func allowSleep() {
- self.assertionTimer?.invalidate()
- self.assertionTimer = nil
- self.releaseSleepAssertion()
- }
-
- // MARK: - Private Methods
-
- private func refreshSleepAssertion() {
- guard self.isUserSessionActive else { return }
-
- // Release existing assertion
- if let assertionID = sleepAssertionID {
- IOPMAssertionRelease(assertionID)
- }
-
- // Create new assertion
- var assertionID: IOPMAssertionID = 0
- let reason = String(localized: "Caffeine prevents sleep") as CFString
- let result = IOPMAssertionCreateWithDescription(
+ guard self.assertionID == nil else { return }
+ var id: IOPMAssertionID = 0
+ let result = IOPMAssertionCreateWithName(
kIOPMAssertPreventUserIdleDisplaySleep as CFString,
- reason,
- nil as CFString?,
- nil as CFString?,
- nil as CFString?,
- 8, // Timeout after 8 seconds
- nil as CFString?,
- &assertionID
+ IOPMAssertionLevel(kIOPMAssertionLevelOn),
+ String(localized: "Caffeine prevents sleep") as CFString,
+ &id
)
-
if result == kIOReturnSuccess {
- self.sleepAssertionID = assertionID
+ self.assertionID = id
}
}
- private func releaseSleepAssertion() {
- if let assertionID = sleepAssertionID {
+ func allowSleep() {
+ if let assertionID {
IOPMAssertionRelease(assertionID)
- self.sleepAssertionID = nil
+ self.assertionID = nil
}
}
-
- private func setupWorkspaceNotifications() {
- let notificationCenter = NSWorkspace.shared.notificationCenter
-
- notificationCenter.addObserver(
- self,
- selector: #selector(self.sessionDidResignActive),
- name: NSWorkspace.sessionDidResignActiveNotification,
- object: nil
- )
-
- notificationCenter.addObserver(
- self,
- selector: #selector(self.sessionDidBecomeActive),
- name: NSWorkspace.sessionDidBecomeActiveNotification,
- object: nil
- )
- }
-
- @objc
- private func sessionDidResignActive() {
- self.isUserSessionActive = false
- }
-
- @objc
- private func sessionDidBecomeActive() {
- self.isUserSessionActive = true
- }
}
diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
index 8d3524d..b887b0d 100644
--- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
+++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
@@ -17,7 +17,7 @@ class CaffeineViewModel: ObservableObject {
@Published var isActive = false
@Published var timeRemaining: TimeInterval?
@Published var showPreferences = false
- let protectedLid = ProtectedLidManager()
+ let automaticLid = AutomaticLidManager()
// MARK: - Private Properties
@@ -33,10 +33,9 @@ class CaffeineViewModel: ObservableObject {
self.timeRemaining = nil
self.setupObservers()
- self.protectedLid.didStop = { [weak self] in
- guard let self, self.isActive else { return }
- SleepPreventionManager.shared.preventSleep()
- self.updateActivitySimulation(enabled: UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive))
+ self.automaticLid.didFail = { [weak self] in
+ self?.deactivate()
+ self?.showPreferences = true
}
// Check if we should activate at launch
@@ -114,12 +113,13 @@ class CaffeineViewModel: ObservableObject {
}
self.isActive = true
- if !self.protectedLid.isEngaged {
- SleepPreventionManager.shared.preventSleep()
- }
-
- if !self.protectedLid.isEngaged, UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) {
- ActivitySimulator.shared.startMonitoring()
+ SleepPreventionManager.shared.preventSleep()
+ self.updateActivitySimulation(enabled: UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive))
+ if !self.automaticLid.isEngaged {
+ Task { [weak self] in
+ guard let self, self.isActive else { return }
+ await self.automaticLid.start()
+ }
}
}
@@ -128,31 +128,20 @@ class CaffeineViewModel: ObservableObject {
self.cancelTimers()
self.timeRemaining = nil
self.isActive = false
- self.protectedLid.stop()
- SleepPreventionManager.shared.allowSleep()
- ActivitySimulator.shared.stopMonitoring()
- }
-
- /// Explicit action: authenticate first, then lock before allowing lid closure.
- func startProtectedLidSession() {
- guard !self.protectedLid.isEngaged else { return }
- if !self.isActive {
- self.activate()
- }
+ self.automaticLid.stop()
SleepPreventionManager.shared.allowSleep()
ActivitySimulator.shared.stopMonitoring()
- Task { await self.protectedLid.start() }
}
/// Updates activity simulation based on preference
func updateActivitySimulation(enabled: Bool) {
- if enabled, !self.protectedLid.isEngaged {
+ if enabled, !self.automaticLid.isLidClosed {
// Trigger the Accessibility permission prompt by posting a no-op event
// This prompts for "Events" permission which CGEvent.post requires
ActivitySimulator.shared.requestPermission()
}
- if enabled, self.isActive, !self.protectedLid.isEngaged {
+ if enabled, self.isActive, !self.automaticLid.isLidClosed {
ActivitySimulator.shared.startMonitoring()
} else {
ActivitySimulator.shared.stopMonitoring()
diff --git a/src/Caffeine/Classes/Views/AutomaticLidControls.swift b/src/Caffeine/Classes/Views/AutomaticLidControls.swift
new file mode 100644
index 0000000..5acaaa6
--- /dev/null
+++ b/src/Caffeine/Classes/Views/AutomaticLidControls.swift
@@ -0,0 +1,38 @@
+import SwiftUI
+
+struct AutomaticLidControls: View {
+ @ObservedObject var manager: AutomaticLidManager
+ let isActive: Bool
+ let toggle: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Automatic lid control").font(.headline)
+ Text(
+ "While Caffeine is active, closing the lid sets the display and keyboard brightness to zero and keeps the Mac awake without locking. Opening the lid locks your Mac, then restores the previous brightness. Caffeine stays active for the next lid cycle."
+ )
+ .font(.system(size: 12)).fixedSize(horizontal: false, vertical: true)
+ Text(
+ "The helper needs administrator approval only for its first installation. Activation does not lock your Mac. Keep the laptop ventilated while it works with the lid closed."
+ )
+ .font(.system(size: 11)).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if self.manager.isPreparing {
+ HStack {
+ ProgressView().controlSize(.small)
+ Text("Preparing automatic lid control…")
+ }
+ } else if self.manager.isRunning {
+ Text("Ready: lights off on close, lock on open.").foregroundStyle(.secondary)
+ }
+ if let error = self.manager.errorMessage {
+ Text(error).font(.system(size: 11)).foregroundStyle(.red)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ Button(
+ self.isActive ? String(localized: "Deactivate Caffeine") : String(localized: "Activate Caffeine"),
+ action: self.toggle
+ )
+ }
+ }
+}
diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift
index dff1ab2..62303b1 100644
--- a/src/Caffeine/Classes/Views/MenuBarController.swift
+++ b/src/Caffeine/Classes/Views/MenuBarController.swift
@@ -37,16 +37,6 @@ class MenuBarController: NSObject {
}
}
- @objc
- private func toggleProtectedLid() {
- if self.viewModel.protectedLid.isEngaged {
- self.viewModel.protectedLid.stop()
- } else {
- // Show the explanation and explicit lock action before starting.
- self.showPreferencesWindow()
- }
- }
-
private func setupMenuBar() {
self.statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
@@ -109,12 +99,12 @@ class MenuBarController: NSObject {
}
let lidItem = NSMenuItem(
- title: self.viewModel.protectedLid.isEngaged
- ? String(localized: "Stop protected lid mode")
- : String(localized: "Lock & keep awake with lid closed…"),
- action: #selector(self.toggleProtectedLid), keyEquivalent: ""
+ title: self.viewModel.automaticLid.isPreparing
+ ? String(localized: "Preparing automatic lid control…")
+ : String(localized: "Lid: lights off on close, lock on open"),
+ action: nil, keyEquivalent: ""
)
- lidItem.target = self
+ lidItem.isEnabled = false
menu.addItem(lidItem)
menu.addItem(NSMenuItem.separator())
diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift
index c6b5667..b44d26e 100644
--- a/src/Caffeine/Classes/Views/PreferencesView.swift
+++ b/src/Caffeine/Classes/Views/PreferencesView.swift
@@ -98,8 +98,8 @@ struct PreferencesView: View {
}
Divider().padding(.vertical, 12)
- ProtectedLidControls(manager: self.viewModel.protectedLid) {
- self.viewModel.startProtectedLidSession()
+ AutomaticLidControls(manager: self.viewModel.automaticLid, isActive: self.viewModel.isActive) {
+ self.viewModel.toggleActive()
}
Spacer()
diff --git a/src/Caffeine/Classes/Views/ProtectedLidControls.swift b/src/Caffeine/Classes/Views/ProtectedLidControls.swift
deleted file mode 100644
index bde5b3a..0000000
--- a/src/Caffeine/Classes/Views/ProtectedLidControls.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-import SwiftUI
-
-struct ProtectedLidControls: View {
- @ObservedObject var manager: ProtectedLidManager
- let start: () -> Void
-
- var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text("Protected lid mode").font(.headline)
- Text(
- "Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode."
- )
- .font(.system(size: 12))
- .fixedSize(horizontal: false, vertical: true)
- Text(
- "Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep."
- )
- .font(.system(size: 11))
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- if self.manager.isPreparing {
- HStack {
- ProgressView().controlSize(.small)
- Text("Preparing protected lid mode…")
- }
- } else if self.manager.isRunning {
- Text("Protected lid mode is active.").foregroundStyle(.secondary)
- }
- if let error = self.manager.errorMessage {
- Text(error).font(.system(size: 11)).foregroundStyle(.red)
- .fixedSize(horizontal: false, vertical: true)
- }
- if self.manager.isEngaged {
- Button("Stop protected lid mode") { self.manager.stop() }
- } else {
- Button("Lock & keep awake with lid closed…", action: self.start)
- }
- }
- }
-}
diff --git a/src/Caffeine/Resources/ar.lproj/Localizable.strings b/src/Caffeine/Resources/ar.lproj/Localizable.strings
index d7bf0f5..0613086 100644
--- a/src/Caffeine/Resources/ar.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings
@@ -27,14 +27,16 @@
"Welcome to Caffeine" = "مرحبًا بك في Caffeine";
"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net";
"Caffeine prevents sleep" = "Caffeine يمنع السكون";
-"Protected lid mode requires a Mac laptop." = "يتطلب وضع الغطاء المحمي جهاز Mac محمولًا.";
-"The macOS lock service is unavailable. Protected lid mode was not enabled." = "خدمة قفل macOS غير متاحة. لم يتم تفعيل وضع الغطاء المحمي.";
-"Protected lid mode stopped because the battery is low or the Mac is too warm." = "توقف وضع الغطاء المحمي بسبب انخفاض البطارية أو ارتفاع حرارة الجهاز.";
-"Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again." = "تعذر التأكد من تشغيل وضع الغطاء المحمي، فتم إيقافه. وافق على طلب صلاحية المسؤول وتأكد من عدم وجود تطبيق آخر يمنع السكون عند إغلاق الغطاء، ثم أعد المحاولة.";
-"Protected lid mode" = "وضع الغطاء المحمي";
-"Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode." = "يقفل جهازك الآن، ثم يبقي المهام تعمل والغطاء مغلق والشاشات مطفأة. عند العودة، افتح القفل بكلمة مرور حساب Mac أو Touch ID. ينتهي هذا الوضع عند فتح القفل.";
-"Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep." = "يتطلب موافقة المسؤول مرة واحدة لتثبيت مساعد صغير. لن تطلب الجلسات التالية صلاحية المسؤول مجددًا. يعود السكون الطبيعي عند إيقاف Caffeine أو انتهاء المؤقت أو إغلاق التطبيق. أبقِ الجهاز في مكان جيد التهوية؛ فهذا الوضع يستهلك بطارية أكثر من السكون.";
-"Preparing protected lid mode…" = "جارٍ إعداد وضع الغطاء المحمي…";
-"Protected lid mode is active." = "وضع الغطاء المحمي مفعّل.";
-"Stop protected lid mode" = "إيقاف وضع الغطاء المحمي";
-"Lock & keep awake with lid closed…" = "قفل الجهاز والعمل والغطاء مغلق…";
+"Automatic lid control is unavailable on this Mac. Caffeine was deactivated." = "التحكم التلقائي بالغطاء غير متاح على هذا الجهاز. تم إيقاف Caffeine.";
+"Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work." = "التحكم بإضاءة الكيبورد غير متاح على هذا الجهاز؛ سيستمر التحكم التلقائي بالشاشة والغطاء.";
+"Caffeine stopped because the battery is low or the Mac is too warm." = "توقف Caffeine بسبب انخفاض البطارية أو ارتفاع حرارة الجهاز.";
+"macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display." = "لم يؤكد macOS قفل الجهاز، لذا بقيت الشاشة مظلمة. استخدم مفاتيح السطوع إذا احتجت إلى استعادة إضاءة الشاشة.";
+"Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again." = "تعذر تشغيل التحكم التلقائي بالغطاء أو توقف عن الاستجابة، فتم إيقاف Caffeine. تحقق من طلب صلاحية المسؤول ومن التطبيقات الأخرى التي تتحكم بالسكون، ثم أعد المحاولة.";
+"Automatic lid control" = "التحكم التلقائي بالغطاء";
+"While Caffeine is active, closing the lid sets the display and keyboard brightness to zero and keeps the Mac awake without locking. Opening the lid locks your Mac, then restores the previous brightness. Caffeine stays active for the next lid cycle." = "أثناء تفعيل Caffeine، يؤدي إغلاق الغطاء إلى خفض سطوع الشاشة وإضاءة الكيبورد إلى صفر، مع إبقاء الجهاز يعمل دون قفله. عند فتح الغطاء يُقفل الجهاز، ثم تعود الإضاءة إلى مستواها السابق. يبقى Caffeine مفعّلًا لتكرار ذلك تلقائيًا.";
+"The helper needs administrator approval only for its first installation. Activation does not lock your Mac. Keep the laptop ventilated while it works with the lid closed." = "يحتاج المساعد إلى صلاحية المسؤول عند تثبيته لأول مرة فقط. تفعيل Caffeine لا يقفل جهازك. أبقِ الجهاز جيد التهوية أثناء العمل والغطاء مغلق.";
+"Preparing automatic lid control…" = "جارٍ إعداد التحكم التلقائي بالغطاء…";
+"Ready: lights off on close, lock on open." = "جاهز: إطفاء الإضاءة عند الإغلاق، والقفل عند الفتح.";
+"Deactivate Caffeine" = "إيقاف Caffeine";
+"Activate Caffeine" = "تفعيل Caffeine";
+"Lid: lights off on close, lock on open" = "الغطاء: إطفاء الإضاءة عند الإغلاق، والقفل عند الفتح";
diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings
index 2c9d3d5..4f0ecac 100644
--- a/src/Caffeine/Resources/en.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/en.lproj/Localizable.strings
@@ -43,16 +43,17 @@
/* System messages */
"Caffeine prevents sleep" = "Caffeine prevents sleep";
-
-/* Protected lid mode */
-"Protected lid mode requires a Mac laptop." = "Protected lid mode requires a Mac laptop.";
-"The macOS lock service is unavailable. Protected lid mode was not enabled." = "The macOS lock service is unavailable. Protected lid mode was not enabled.";
-"Protected lid mode stopped because the battery is low or the Mac is too warm." = "Protected lid mode stopped because the battery is low or the Mac is too warm.";
-"Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again." = "Protected lid mode could not be confirmed and has been stopped. Allow the administrator request, check that no other app disables lid sleep, and try again.";
-"Protected lid mode" = "Protected lid mode";
-"Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode." = "Locks your Mac now, then keeps tasks running with the lid closed and displays off. Unlock with your Mac password or Touch ID when you return. Unlocking ends this mode.";
-"Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep." = "Requires administrator approval once to install a small helper. Later sessions need no administrator prompt. Normal sleep returns when Caffeine stops, the timer expires, or the app exits. Keep the Mac ventilated; this uses more battery than sleep.";
-"Preparing protected lid mode…" = "Preparing protected lid mode…";
-"Protected lid mode is active." = "Protected lid mode is active.";
-"Stop protected lid mode" = "Stop protected lid mode";
-"Lock & keep awake with lid closed…" = "Lock & keep awake with lid closed…";
+/* Automatic lid control */
+"Automatic lid control is unavailable on this Mac. Caffeine was deactivated." = "Automatic lid control is unavailable on this Mac. Caffeine was deactivated.";
+"Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work." = "Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work.";
+"Caffeine stopped because the battery is low or the Mac is too warm." = "Caffeine stopped because the battery is low or the Mac is too warm.";
+"macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display." = "macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display.";
+"Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again." = "Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again.";
+"Automatic lid control" = "Automatic lid control";
+"While Caffeine is active, closing the lid sets the display and keyboard brightness to zero and keeps the Mac awake without locking. Opening the lid locks your Mac, then restores the previous brightness. Caffeine stays active for the next lid cycle." = "While Caffeine is active, closing the lid sets the display and keyboard brightness to zero and keeps the Mac awake without locking. Opening the lid locks your Mac, then restores the previous brightness. Caffeine stays active for the next lid cycle.";
+"The helper needs administrator approval only for its first installation. Activation does not lock your Mac. Keep the laptop ventilated while it works with the lid closed." = "The helper needs administrator approval only for its first installation. Activation does not lock your Mac. Keep the laptop ventilated while it works with the lid closed.";
+"Preparing automatic lid control…" = "Preparing automatic lid control…";
+"Ready: lights off on close, lock on open." = "Ready: lights off on close, lock on open.";
+"Deactivate Caffeine" = "Deactivate Caffeine";
+"Activate Caffeine" = "Activate Caffeine";
+"Lid: lights off on close, lock on open" = "Lid: lights off on close, lock on open";
diff --git a/tests/LidCycleTests.swift b/tests/LidCycleTests.swift
new file mode 100644
index 0000000..2789e89
--- /dev/null
+++ b/tests/LidCycleTests.swift
@@ -0,0 +1,30 @@
+import Foundation
+
+@main
+struct LidCycleTests {
+ static func main() {
+ var state = LidCycleState()
+ precondition(state.update(closed: false) == nil, "Activation with an open lid must not lock")
+ precondition(state.update(closed: true) == .darken, "Closing only darkens the display and keyboard")
+ precondition(!state.lockPending, "Closing must leave the session unlocked")
+ precondition(state.update(closed: true) == nil, "Duplicate close notifications must be ignored")
+ precondition(state.update(closed: false) == .lock, "Opening must request native locking")
+ precondition(state.lockPending, "Do not restore brightness before lock confirmation")
+ precondition(state.update(closed: false) == nil, "Polling must not repeatedly lock an open lid")
+ precondition(state.confirmLock(), "Restore both brightness levels after lock confirmation")
+ precondition(!state.lockPending)
+ precondition(!state.confirmLock(), "Ignore duplicate confirmations")
+ precondition(state.update(closed: false) == nil, "Unlocking must not retrigger a lock")
+ precondition(state.update(closed: true) == .darken, "Remain armed for the next cycle")
+ precondition(state.update(closed: false) == .lock)
+ precondition(state.update(closed: true) == .darken, "Handle closing again while locking is pending")
+ precondition(!state.confirmLock(), "Do not light up a lid that was closed again")
+ precondition(state.update(closed: false) == .lock)
+ precondition(state.confirmLock())
+ var initiallyClosed = LidCycleState()
+ precondition(initiallyClosed.update(closed: true) == .darken, "Starting closed must not lock")
+ precondition(!initiallyClosed.lockPending)
+ precondition(initiallyClosed.update(closed: false) == .lock)
+ print("20 lid transition assertions passed")
+ }
+}
From 1da837902dece40dfb859bc5f8df4a9e69878527 Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Sun, 6 Sep 2026 05:55:19 +0300
Subject: [PATCH 3/6] Fix rapid lid-session restarts and simplify settings to a
checkbox
---
CHANGELOG.md | 4 ++
docs/PROTECTED-LID.md | 14 ++--
.../Classes/Models/AutomaticLidManager.swift | 58 ++--------------
.../Classes/Models/LidHelperSession.swift | 69 +++++++++++++++++++
.../ViewModels/CaffeineViewModel.swift | 23 ++++++-
.../Classes/Views/AutomaticLidControls.swift | 40 ++++-------
.../Classes/Views/MenuBarController.swift | 20 +++---
.../Classes/Views/PreferencesView.swift | 13 ++--
.../Resources/ar.lproj/Localizable.strings | 4 ++
.../Resources/en.lproj/Localizable.strings | 4 ++
tests/LidHelperSessionTests.swift | 36 ++++++++++
tests/test_lid_watchdog.py | 18 +++++
12 files changed, 204 insertions(+), 99 deletions(-)
create mode 100644 src/Caffeine/Classes/Models/LidHelperSession.swift
create mode 100644 tests/LidHelperSessionTests.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f1b413c..d458aac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,12 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- Replaced the large lid-control panel with one persistent checkbox and a short description; enabled by default.
+
- Replaced the earlier lock-at-activation session button with automatic behavior. Activation no longer locks; unlocking no longer ends Caffeine.
- Disabled upstream automatic updates for the separately identified direct-distribution fork.
- Improved Ukrainian translation.
### Fixed
+- Rapid off/on toggling waits for the previous helper session to finish restoring power instead of failing and opening preferences. Canceled startup attempts cannot overwrite a newer session.
+
- Removed the two-second gap between sleep-prevention assertions, and suppressed simulated mouse activity while the lid is closed.
- Timer no longer stays active and shows negative seconds after the Mac sleeps past the activation period.
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
index f62b8f6..fc3a395 100644
--- a/docs/PROTECTED-LID.md
+++ b/docs/PROTECTED-LID.md
@@ -1,7 +1,9 @@
# Automatic lid control
This direct-distribution fork couples lid behavior to the normal Caffeine toggle.
-There is no separate protected-session button and activation never requests a lock.
+The **Automatic lid control** checkbox enables this behavior and defaults to on.
+Unchecking it keeps ordinary Caffeine active without lid handling. There is no
+separate session button, and activation never requests a lock.
## Behavior
@@ -20,8 +22,8 @@ Manual locking and managed security policies are not overridden or undone.
## Installation and usage
Build with `bash scripts/build-lid.sh`, open `dist/Caffeine Lid.app`, and activate
-Caffeine using the menu-bar cup or **Activate Caffeine** in preferences. Wait for
-**Ready: lights off on close, lock on open** before testing the lid. First activation
+Caffeine using the menu-bar cup with the **Automatic lid control** checkbox enabled. Wait for the brief
+**Preparing…** message to disappear before testing the lid. First activation
installs a limited helper using the native administrator dialog. Later activations
need no administrator prompt. The previously installed version-1 helper is reused;
this behavior change requires no new privileged operation or installation.
@@ -114,8 +116,10 @@ bash scripts/build-lid.sh
swiftformat .
```
-The existing 16 isolated watchdog tests cover heartbeat expiration, client/helper
-crashes, failed enable/restore, conflicts and untrusted request data. The transition
+The 17 isolated watchdog/client tests cover heartbeat expiration, client/helper
+crashes, failed enable/restore, conflicts and untrusted request data. The actual
+Swift client is also tested against the helper for rapid off/on cycles and
+cancellation during startup; restarts wait for prior-session cleanup. The transition
suite covers activation without locking, close without locking, open with locking,
confirmation-before-brightness restoration, repeated events, successive cycles and
rapid reclosure while locking. Neither suite closes the physical lid or locks the
diff --git a/src/Caffeine/Classes/Models/AutomaticLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
index 12b9944..ada2c02 100644
--- a/src/Caffeine/Classes/Models/AutomaticLidManager.swift
+++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
@@ -15,7 +15,7 @@ final class AutomaticLidManager: ObservableObject {
var didFail: (() -> Void)?
private var sessionID: UUID?
- private var leaseURL: URL?
+ private let helperSession = LidHelperSession()
private var monitor: Task?
private var lockTask: Task?
private var cycle = LidCycleState()
@@ -26,9 +26,6 @@ final class AutomaticLidManager: ObservableObject {
private var lastActivity = Date.distantPast
private let loginLibrary = dlopen("/System/Library/PrivateFrameworks/login.framework/login", RTLD_LAZY)
private let helperDirectory = URL(fileURLWithPath: "/Library/Application Support/CaffeineLid", isDirectory: true)
- private var statusURL: URL {
- self.helperDirectory.appendingPathComponent("status")
- }
var isEngaged: Bool {
self.isPreparing || self.isRunning
@@ -72,21 +69,7 @@ final class AutomaticLidManager: ObservableObject {
try await Self.authorize(Self.installCommand(script: String(contentsOf: scriptURL, encoding: .utf8)))
}
guard self.sessionID == id else { return }
- for _ in 0..<50 {
- if self.helperIsInstalled(), let status = self.readStatus(), status != "offline" {
- break
- }
- try await Task.sleep(for: .milliseconds(100))
- }
- guard
- self.helperIsInstalled(), let status = self.readStatus(), status != "offline",
- !status.hasPrefix("active:"), !status.hasPrefix("ready:") else { throw LidError.unavailable }
- self.leaseURL = self.helperDirectory.appendingPathComponent("request/lease")
- try self.refreshLease(allowOverride: false)
- try await self.waitForStatus("ready", id: id)
- // The helper only manages power. No lock is requested at activation.
- try self.refreshLease(allowOverride: true)
- try await self.waitForStatus("active", id: id)
+ try await self.helperSession.start(id: id) { self.helperIsInstalled() }
guard self.sessionID == id else { return }
self.isPreparing = false
self.isRunning = true
@@ -116,9 +99,6 @@ final class AutomaticLidManager: ObservableObject {
self.display.rememberBrightness()
self.keyboard.rememberBrightness()
}
- guard self.readStatus() == "active:\(id.uuidString)" else { self.fail()
- return
- }
if Self.shouldStopForPower() {
self
.fail(
@@ -128,7 +108,7 @@ final class AutomaticLidManager: ObservableObject {
)
return
}
- do { try self.refreshLease(allowOverride: true) } catch { self.fail()
+ do { try self.helperSession.renew() } catch { self.fail()
return
}
heartbeat = Date()
@@ -142,19 +122,12 @@ final class AutomaticLidManager: ObservableObject {
}
func stop() {
- let oldID = self.sessionID
self.sessionID = nil
self.monitor?.cancel()
self.monitor = nil
self.lidMonitor?.stop()
self.lidMonitor = nil
- if
- let leaseURL, let oldID,
- let value = try? String(contentsOf: leaseURL, encoding: .utf8), value.hasSuffix(":" + oldID.uuidString)
- {
- try? FileManager.default.removeItem(at: leaseURL)
- }
- self.leaseURL = nil
+ self.helperSession.stop()
self.isPreparing = false
self.isRunning = false
if self.activityID != 0 {
@@ -231,29 +204,6 @@ final class AutomaticLidManager: ObservableObject {
self.didFail?()
}
- private func refreshLease(allowOverride: Bool) throws {
- guard let leaseURL, let sessionID else { throw LidError.unavailable }
- // "locked" is the legacy v1 helper's activation opcode. Reusing its
- // power-only protocol avoids another administrator installation prompt.
- let value = "\(allowOverride ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(sessionID.uuidString)"
- try value.write(to: leaseURL, atomically: true, encoding: .utf8)
- }
-
- private func readStatus() -> String? {
- try? String(contentsOf: self.statusURL, encoding: .utf8)
- }
-
- private func waitForStatus(_ status: String, id: UUID) async throws {
- for _ in 0..<50 {
- guard self.sessionID == id else { throw CancellationError() }
- if self.readStatus() == "\(status):\(id.uuidString)" {
- return
- }
- try await Task.sleep(for: .milliseconds(100))
- }
- throw LidError.unavailable
- }
-
private func helperIsInstalled() -> Bool {
let owner = try? String(contentsOf: self.helperDirectory.appendingPathComponent("owner"), encoding: .utf8)
let version = try? String(contentsOf: self.helperDirectory.appendingPathComponent("version"), encoding: .utf8)
diff --git a/src/Caffeine/Classes/Models/LidHelperSession.swift b/src/Caffeine/Classes/Models/LidHelperSession.swift
new file mode 100644
index 0000000..a014689
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LidHelperSession.swift
@@ -0,0 +1,69 @@
+import Foundation
+
+/// Serializes each client handshake with the helper's asynchronous cleanup.
+@MainActor
+final class LidHelperSession {
+ private let directory: URL
+ private var sessionID: UUID?
+ private var leaseURL: URL {
+ self.directory.appendingPathComponent("request/lease")
+ }
+
+ init(directory: URL = URL(fileURLWithPath: "/Library/Application Support/CaffeineLid", isDirectory: true)) {
+ self.directory = directory
+ }
+
+ func start(id: UUID, isInstalled: () -> Bool = { true }) async throws {
+ self.sessionID = id
+ // Off/on can happen within the helper's one-second polling interval.
+ // Leave the old request removed until the helper has restored power.
+ try await self.wait(id: id) {
+ guard isInstalled(), let status = self.status else { return false }
+ return status == "idle" || status.hasPrefix("failed:") || status.hasPrefix("conflict:")
+ }
+ try self.writeRequest(active: false, id: id)
+ try await self.wait(id: id) { self.status == "ready:\(id.uuidString)" }
+ try self.writeRequest(active: true, id: id)
+ try await self.wait(id: id) { self.status == "active:\(id.uuidString)" }
+ }
+
+ func renew() throws {
+ guard let id = self.sessionID, self.status == "active:\(id.uuidString)" else { throw SessionError.unavailable }
+ try self.writeRequest(active: true, id: id)
+ }
+
+ func stop() {
+ let oldID = self.sessionID
+ self.sessionID = nil
+ if
+ let oldID, let value = try? String(contentsOf: self.leaseURL, encoding: .utf8),
+ value.hasSuffix(":" + oldID.uuidString)
+ {
+ try? FileManager.default.removeItem(at: self.leaseURL)
+ }
+ }
+
+ private var status: String? {
+ try? String(contentsOf: self.directory.appendingPathComponent("status"), encoding: .utf8)
+ }
+
+ private func writeRequest(active: Bool, id: UUID) throws {
+ guard self.sessionID == id, !Task.isCancelled else { throw CancellationError() }
+ // Keep the installed version-1 helper's historical activation opcode.
+ let value = "\(active ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(id.uuidString)"
+ try value.write(to: self.leaseURL, atomically: true, encoding: .utf8)
+ }
+
+ private func wait(id: UUID, until ready: () -> Bool) async throws {
+ for _ in 0..<120 {
+ guard self.sessionID == id, !Task.isCancelled else { throw CancellationError() }
+ if ready() {
+ return
+ }
+ try await Task.sleep(for: .milliseconds(100))
+ }
+ throw SessionError.unavailable
+ }
+
+ private enum SessionError: Error { case unavailable }
+}
diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
index b887b0d..0ecdb8f 100644
--- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
+++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
@@ -32,6 +32,7 @@ class CaffeineViewModel: ObservableObject {
self.isActive = false
self.timeRemaining = nil
+ UserDefaults.standard.register(defaults: [PreferenceKeys.automaticLidControl: true])
self.setupObservers()
self.automaticLid.didFail = { [weak self] in
self?.deactivate()
@@ -115,9 +116,11 @@ class CaffeineViewModel: ObservableObject {
self.isActive = true
SleepPreventionManager.shared.preventSleep()
self.updateActivitySimulation(enabled: UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive))
- if !self.automaticLid.isEngaged {
+ if UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl), !self.automaticLid.isEngaged {
Task { [weak self] in
- guard let self, self.isActive else { return }
+ guard
+ let self, self.isActive,
+ UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) else { return }
await self.automaticLid.start()
}
}
@@ -133,6 +136,21 @@ class CaffeineViewModel: ObservableObject {
ActivitySimulator.shared.stopMonitoring()
}
+ func updateAutomaticLidControl(enabled: Bool) {
+ UserDefaults.standard.set(enabled, forKey: PreferenceKeys.automaticLidControl)
+ if enabled, self.isActive {
+ Task { [weak self] in
+ guard
+ let self, self.isActive,
+ UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) else { return }
+ await self.automaticLid.start()
+ }
+ } else {
+ self.automaticLid.stop()
+ self.automaticLid.errorMessage = nil
+ }
+ }
+
/// Updates activity simulation based on preference
func updateActivitySimulation(enabled: Bool) {
if enabled, !self.automaticLid.isLidClosed {
@@ -220,5 +238,6 @@ enum PreferenceKeys {
static let defaultDuration = "CADefaultDuration"
static let suppressLaunchMessage = "CASuppressLaunchMessage"
static let deactivateOnManualSleep = "CADeactivateOnManualSleep"
+ static let automaticLidControl = "CAAutomaticLidControl"
static let keepAppsActive = "CAKeepAppsActive"
}
diff --git a/src/Caffeine/Classes/Views/AutomaticLidControls.swift b/src/Caffeine/Classes/Views/AutomaticLidControls.swift
index 5acaaa6..de224e1 100644
--- a/src/Caffeine/Classes/Views/AutomaticLidControls.swift
+++ b/src/Caffeine/Classes/Views/AutomaticLidControls.swift
@@ -2,37 +2,27 @@ import SwiftUI
struct AutomaticLidControls: View {
@ObservedObject var manager: AutomaticLidManager
- let isActive: Bool
- let toggle: () -> Void
+ @Binding var isEnabled: Bool
var body: some View {
VStack(alignment: .leading, spacing: 8) {
- Text("Automatic lid control").font(.headline)
- Text(
- "While Caffeine is active, closing the lid sets the display and keyboard brightness to zero and keeps the Mac awake without locking. Opening the lid locks your Mac, then restores the previous brightness. Caffeine stays active for the next lid cycle."
- )
- .font(.system(size: 12)).fixedSize(horizontal: false, vertical: true)
- Text(
- "The helper needs administrator approval only for its first installation. Activation does not lock your Mac. Keep the laptop ventilated while it works with the lid closed."
- )
- .font(.system(size: 11)).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- if self.manager.isPreparing {
- HStack {
- ProgressView().controlSize(.small)
- Text("Preparing automatic lid control…")
- }
- } else if self.manager.isRunning {
- Text("Ready: lights off on close, lock on open.").foregroundStyle(.secondary)
+ Toggle("Automatic lid control", isOn: self.$isEnabled)
+ .font(.system(size: 13))
+ Text("Turns off screen and keyboard lighting when closed, and locks your Mac when opened.")
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .padding(.leading, 20)
+ .fixedSize(horizontal: false, vertical: true)
+ if self.isEnabled, self.manager.isPreparing {
+ Text("Preparing…")
+ .font(.system(size: 11)).foregroundColor(.secondary)
+ .padding(.leading, 20)
}
- if let error = self.manager.errorMessage {
- Text(error).font(.system(size: 11)).foregroundStyle(.red)
+ if self.isEnabled, let error = self.manager.errorMessage {
+ Text(error).font(.system(size: 11)).foregroundColor(.red)
+ .padding(.leading, 20)
.fixedSize(horizontal: false, vertical: true)
}
- Button(
- self.isActive ? String(localized: "Deactivate Caffeine") : String(localized: "Activate Caffeine"),
- action: self.toggle
- )
}
}
}
diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift
index 62303b1..8460537 100644
--- a/src/Caffeine/Classes/Views/MenuBarController.swift
+++ b/src/Caffeine/Classes/Views/MenuBarController.swift
@@ -98,15 +98,17 @@ class MenuBarController: NSObject {
menu.addItem(NSMenuItem.separator())
}
- let lidItem = NSMenuItem(
- title: self.viewModel.automaticLid.isPreparing
- ? String(localized: "Preparing automatic lid control…")
- : String(localized: "Lid: lights off on close, lock on open"),
- action: nil, keyEquivalent: ""
- )
- lidItem.isEnabled = false
- menu.addItem(lidItem)
- menu.addItem(NSMenuItem.separator())
+ if UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) {
+ let lidItem = NSMenuItem(
+ title: self.viewModel.automaticLid.isPreparing
+ ? String(localized: "Preparing automatic lid control…")
+ : String(localized: "Lid: lights off on close, lock on open"),
+ action: nil, keyEquivalent: ""
+ )
+ lidItem.isEnabled = false
+ menu.addItem(lidItem)
+ menu.addItem(NSMenuItem.separator())
+ }
// Duration options in submenu
let activateForItem = NSMenuItem(
diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift
index b44d26e..a085181 100644
--- a/src/Caffeine/Classes/Views/PreferencesView.swift
+++ b/src/Caffeine/Classes/Views/PreferencesView.swift
@@ -13,6 +13,7 @@ struct PreferencesView: View {
@AppStorage(PreferenceKeys.activateAtLaunch) private var activateAtLaunch = false
@AppStorage(PreferenceKeys.suppressLaunchMessage) private var suppressLaunchMessage = false
@AppStorage(PreferenceKeys.deactivateOnManualSleep) private var deactivateOnManualSleep = false
+ @AppStorage(PreferenceKeys.automaticLidControl) private var automaticLidControl = true
@AppStorage(PreferenceKeys.keepAppsActive) private var keepAppsActive = false
var body: some View {
@@ -97,10 +98,14 @@ struct PreferencesView: View {
.padding(.leading, 20)
}
- Divider().padding(.vertical, 12)
- AutomaticLidControls(manager: self.viewModel.automaticLid, isActive: self.viewModel.isActive) {
- self.viewModel.toggleActive()
- }
+ Divider().padding(.vertical, 8)
+ AutomaticLidControls(manager: self.viewModel.automaticLid, isEnabled: Binding(
+ get: { self.automaticLidControl },
+ set: { enabled in
+ self.automaticLidControl = enabled
+ self.viewModel.updateAutomaticLidControl(enabled: enabled)
+ }
+ ))
Spacer()
.frame(height: 30)
diff --git a/src/Caffeine/Resources/ar.lproj/Localizable.strings b/src/Caffeine/Resources/ar.lproj/Localizable.strings
index 0613086..a2de611 100644
--- a/src/Caffeine/Resources/ar.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings
@@ -40,3 +40,7 @@
"Deactivate Caffeine" = "إيقاف Caffeine";
"Activate Caffeine" = "تفعيل Caffeine";
"Lid: lights off on close, lock on open" = "الغطاء: إطفاء الإضاءة عند الإغلاق، والقفل عند الفتح";
+
+/* Minimal lid preference */
+"Turns off screen and keyboard lighting when closed, and locks your Mac when opened." = "يطفئ إضاءة الشاشة والكيبورد عند إغلاق الغطاء، ويقفل جهازك عند فتحه.";
+"Preparing…" = "جارٍ الإعداد…";
diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings
index 4f0ecac..9bf4785 100644
--- a/src/Caffeine/Resources/en.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/en.lproj/Localizable.strings
@@ -57,3 +57,7 @@
"Deactivate Caffeine" = "Deactivate Caffeine";
"Activate Caffeine" = "Activate Caffeine";
"Lid: lights off on close, lock on open" = "Lid: lights off on close, lock on open";
+
+/* Minimal lid preference */
+"Turns off screen and keyboard lighting when closed, and locks your Mac when opened." = "Turns off screen and keyboard lighting when closed, and locks your Mac when opened.";
+"Preparing…" = "Preparing…";
diff --git a/tests/LidHelperSessionTests.swift b/tests/LidHelperSessionTests.swift
new file mode 100644
index 0000000..3753c22
--- /dev/null
+++ b/tests/LidHelperSessionTests.swift
@@ -0,0 +1,36 @@
+import Foundation
+
+/// Exercises the real Swift client against the actual helper script with fake
+/// power commands. No display, keyboard, locking, or installed helper is touched.
+@main
+struct LidHelperSessionTests {
+ @MainActor
+ static func main() async throws {
+ let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
+ let client = LidHelperSession(directory: directory)
+ for _ in 0..<4 {
+ try await client.start(id: UUID())
+ try client.renew()
+ client.stop()
+ // Deliberately no delay: the helper still reports the old session.
+ }
+
+ let cancelledID = UUID()
+ let cancelledStart = Task { try await client.start(id: cancelledID) }
+ try await Task.sleep(for: .milliseconds(10))
+ client.stop()
+ let newID = UUID()
+ try await client.start(id: newID)
+ do {
+ try await cancelledStart.value
+ fatalError("Cancelled startup unexpectedly succeeded")
+ } catch is CancellationError {
+ // Expected: the previous continuation cannot write the new lease.
+ }
+ try client.renew()
+ let lease = try String(contentsOf: directory.appendingPathComponent("request/lease"), encoding: .utf8)
+ precondition(lease.hasSuffix(newID.uuidString), "Stale startup replaced the current lease")
+ client.stop()
+ print("Rapid off/on and cancelled-start regression tests passed")
+ }
+}
diff --git a/tests/test_lid_watchdog.py b/tests/test_lid_watchdog.py
index 832999f..9d18886 100644
--- a/tests/test_lid_watchdog.py
+++ b/tests/test_lid_watchdog.py
@@ -95,6 +95,24 @@ def assert_restored(self, process):
self.wait_for(lambda: self.status() in ("idle", "offline") and self.power.read_text().strip() == "0")
self.assertFalse((self.state / "owned").exists())
+ def test_swift_client_rapid_off_on_and_cancelled_start(self):
+ repo = SOURCE.parents[3]
+ executable = self.root / "session-tests"
+ subprocess.run([
+ "swiftc", str(repo / "src/Caffeine/Classes/Models/LidHelperSession.swift"),
+ str(repo / "tests/LidHelperSessionTests.swift"), "-o", str(executable),
+ ], check=True, capture_output=True, text=True)
+ helper = subprocess.Popen(
+ ["/bin/sh", "-c", self.script, "--", str(os.getuid())],
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
+ )
+ self.processes.append(helper)
+ self.wait_for(lambda: self.status() == "idle")
+ result = subprocess.run([str(executable), str(self.state)], capture_output=True, text=True, timeout=45)
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("regression tests passed", result.stdout)
+ self.assert_restored(helper)
+
def test_pending_does_not_change_power(self):
process = self.launch()
self.wait_for(lambda: self.status() == f"ready:{NONCE}")
From 4215e168f0b06ce31436bcbd4bec5e4969f894f7 Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Sun, 6 Sep 2026 06:09:22 +0300
Subject: [PATCH 4/6] Keep a black privacy cover until reopening lock
confirmation settles
---
CHANGELOG.md | 2 +
docs/PROTECTED-LID.md | 18 +++-
.../Classes/Models/AutomaticLidManager.swift | 64 +++++++++++---
.../Classes/Models/LidPrivacyShield.swift | 85 +++++++++++++++++++
.../Classes/Models/LockRevealGate.swift | 18 ++++
.../Resources/ar.lproj/Localizable.strings | 2 +
.../Resources/en.lproj/Localizable.strings | 2 +
tests/LockRevealTests.swift | 43 ++++++++++
8 files changed, 216 insertions(+), 18 deletions(-)
create mode 100644 src/Caffeine/Classes/Models/LidPrivacyShield.swift
create mode 100644 src/Caffeine/Classes/Models/LockRevealGate.swift
create mode 100644 tests/LockRevealTests.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d458aac..9c2654a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Cover desktop pixels with an opaque black panel before lid reopening; keep forcing both brightness levels to zero until native lock confirmation stays stable. Late or unknown lock state never restores brightness on a timeout.
+
- Rapid off/on toggling waits for the previous helper session to finish restoring power instead of failing and opening preferences. Canceled startup attempts cannot overwrite a newer session.
- Removed the two-second gap between sleep-prevention assertions, and suppressed simulated mouse activity while the lid is closed.
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
index fc3a395..61fb4f1 100644
--- a/docs/PROTECTED-LID.md
+++ b/docs/PROTECTED-LID.md
@@ -11,7 +11,7 @@ separate session button, and activation never requests a lock.
| --- | --- |
| Activate with the lid open | Arm monitoring and prevent system sleep; stay unlocked |
| Close the lid | Set built-in screen brightness and keyboard backlight to zero; remain awake without requesting a lock |
-| Open the lid | Request the native macOS lock immediately, then restore both saved brightness levels after lock confirmation |
+| Open the lid | Request the native macOS lock immediately, then restore both saved brightness levels after stable lock confirmation |
| Authenticate | Caffeine remains active; the next lid cycle works automatically |
| Deactivate, timer expires, or quit | Release the helper lease and restore ordinary sleep/brightness |
@@ -50,9 +50,17 @@ provided script; the upstream Xcode project is intentionally unchanged.
- During a closed-lid session, periodic IOKit user-activity assertions postpone
idle behavior. Synthetic mouse activity from the optional Keep apps active
feature is suppressed while the lid is closed.
-- Opening calls `SACLockScreenImmediate`. Brightness restoration waits for
- `CGSSessionScreenIsLocked`. If confirmation fails, the screen remains dark;
- the brightness keys provide manual recovery. No lock is requested on closing.
+- Closing also paints an opaque, non-activating black cover over the internal
+ display before it can wake again. This cover does not authenticate or lock the
+ session; background work continues unlocked as requested.
+- Opening calls `SACLockScreenImmediate`. The cover stays in place and brightness
+ zero is reasserted every 50 ms while `CGSSessionScreenIsLocked` is checked.
+ Confirmation must stay true for 750 ms with the lid open before brightness is
+ restored behind the cover and the cover is removed. Unknown/false lock state or
+ reclosure resets that interval. Timeout never reveals an unlocked desktop;
+ late confirmation can still recover. If the private lock service fails entirely,
+ quitting Caffeine (for example from another display) or restarting the Mac
+ removes the cover. No lock is requested on closing.
macOS controls the timing of panel power and lock-screen presentation. This is
**not a guarantee that zero pixels can ever be visible before locking on every
@@ -110,6 +118,8 @@ approval; ordinary toggling does not.
```sh
python3 -m unittest discover -s tests -v
+swiftc src/Caffeine/Classes/Models/LockRevealGate.swift tests/LockRevealTests.swift -o /tmp/caffeine-reveal-tests
+/tmp/caffeine-reveal-tests
swiftc src/Caffeine/Classes/Models/LidCycleState.swift tests/LidCycleTests.swift -o /tmp/caffeine-lid-tests
/tmp/caffeine-lid-tests
bash scripts/build-lid.sh
diff --git a/src/Caffeine/Classes/Models/AutomaticLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
index ada2c02..174bc83 100644
--- a/src/Caffeine/Classes/Models/AutomaticLidManager.swift
+++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
@@ -18,8 +18,10 @@ final class AutomaticLidManager: ObservableObject {
private let helperSession = LidHelperSession()
private var monitor: Task?
private var lockTask: Task?
+ private var lockRequestID: UUID?
private var cycle = LidCycleState()
private let display = BuiltInDisplay()
+ private let privacyShield = LidPrivacyShield()
private let keyboard = KeyboardBacklight()
private var lidMonitor: LidMonitor?
private var activityID: IOPMAssertionID = 0
@@ -48,12 +50,15 @@ final class AutomaticLidManager: ObservableObject {
localized: "Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work."
)
}
+ self.privacyShield.prepare()
let id = UUID()
self.sessionID = id
self.isPreparing = true
// Monitor before enabling the override: no close/open transition during
// administrator approval or helper startup is silently discarded.
- self.cycle = LidCycleState()
+ if !self.cycle.lockPending {
+ self.cycle = LidCycleState()
+ }
let watcher = LidMonitor { [weak self] closed in self?.handleLid(closed) }
guard watcher.start() else {
self.fail(String(localized: "Automatic lid control is unavailable on this Mac. Caffeine was deactivated."))
@@ -82,6 +87,9 @@ final class AutomaticLidManager: ObservableObject {
return
}
self.handleLid(closed) // Fallback for dropped IOKit notifications.
+ if self.cycle.lockPending {
+ self.darkenLights()
+ }
if Date().timeIntervalSince(heartbeat) >= 1 {
if closed {
// Keep the logical display awake and prevent idle lock;
@@ -136,7 +144,9 @@ final class AutomaticLidManager: ObservableObject {
}
// A lock already requested on opening must finish before making the
// desktop visible, even when a timeout/stop races with that opening.
- if !self.cycle.lockPending {
+ if !self.cycle.lockPending || self.cycle.closed {
+ self.lockTask?.cancel()
+ self.lockRequestID = nil
self.restoreLights()
}
}
@@ -148,32 +158,55 @@ final class AutomaticLidManager: ObservableObject {
guard let action = self.cycle.update(closed: closed) else { return }
switch action {
case .darken:
+ self.lockTask?.cancel()
+ self.lockRequestID = nil
+ // Paint black while the lid closes, before the next wake can show
+ // desktop pixels even if macOS resets hardware brightness itself.
+ self.privacyShield.show()
self.darkenLights()
case .lock:
- // Called directly on the main queue by the IOKit callback.
+ self.privacyShield.show()
self.darkenLights()
self.lockFunction?()
self.lockTask?.cancel()
+ let requestID = UUID()
+ self.lockRequestID = requestID
self.lockTask = Task { [weak self] in
- for attempt in 0..<100 {
- guard let self, !Task.isCancelled else { return }
- if Self.isScreenLocked() == true {
+ var revealGate = LockRevealGate()
+ var attempt = 0
+ while !Task.isCancelled {
+ guard let self, !Task.isCancelled, self.lockRequestID == requestID else { return }
+ // Hardware/ambient brightness may be reapplied during wake.
+ // Continue forcing zero throughout the lock transition.
+ self.darkenLights()
+ let lidClosed = LidMonitor.readClosed() ?? true
+ if
+ revealGate.canReveal(
+ locked: Self.isScreenLocked(), lidClosed: lidClosed,
+ now: ProcessInfo.processInfo.systemUptime
+ )
+ {
if self.cycle.confirmLock() {
self.restoreLights()
}
+ self.lockRequestID = nil
return
}
- if attempt > 0, attempt % 10 == 0 {
+ if attempt > 0, attempt % 20 == 0, Self.isScreenLocked() != true {
self.lockFunction?()
}
- do { try await Task.sleep(for: .milliseconds(100)) } catch { return }
+ if attempt == 150 {
+ // Keep observing after the warning: a late valid lock
+ // may recover, but elapsed time alone never reveals.
+ self
+ .errorMessage =
+ String(
+ localized: "Waiting for macOS to confirm locking. The screen remains covered for privacy."
+ )
+ }
+ attempt += 1
+ do { try await Task.sleep(for: .milliseconds(50)) } catch { return }
}
- // Do not reveal an unlocked desktop if native locking failed.
- self?
- .errorMessage =
- String(
- localized: "macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display."
- )
}
}
}
@@ -184,8 +217,11 @@ final class AutomaticLidManager: ObservableObject {
}
private func restoreLights() {
+ // Raise the backlights behind the cover; only then uncover the secure
+ // lock screen (or complete an explicit stop outside a pending lock).
self.display.restore()
self.keyboard.restore()
+ self.privacyShield.hide()
}
private typealias LockFunction = @convention(c) () -> Void
diff --git a/src/Caffeine/Classes/Models/LidPrivacyShield.swift b/src/Caffeine/Classes/Models/LidPrivacyShield.swift
new file mode 100644
index 0000000..7f4fd5a
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LidPrivacyShield.swift
@@ -0,0 +1,85 @@
+import AppKit
+import QuartzCore
+
+/// Keeps desktop pixels covered before macOS can relight the internal panel.
+/// This is an opaque visual cover, not a substitute for native authentication.
+@MainActor
+final class LidPrivacyShield {
+ private var panel: PrivacyPanel?
+ private var isVisible = false
+
+ init() {
+ NotificationCenter.default.addObserver(
+ self, selector: #selector(self.screensChanged),
+ name: NSApplication.didChangeScreenParametersNotification, object: nil
+ )
+ }
+
+ func prepare() {
+ guard self.panel == nil else { self.updateFrame()
+ return
+ }
+ guard let screen = self.builtInScreen else { return }
+ let panel = PrivacyPanel(
+ contentRect: screen.frame,
+ styleMask: [.borderless, .nonactivatingPanel],
+ backing: .buffered,
+ defer: false
+ )
+ panel.backgroundColor = .black
+ panel.isOpaque = true
+ panel.hasShadow = false
+ panel.hidesOnDeactivate = false
+ panel.ignoresMouseEvents = true
+ panel.isReleasedWhenClosed = false
+ panel.animationBehavior = .none
+ panel.level = .screenSaver
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
+ self.panel = panel
+ }
+
+ func show() {
+ self.prepare()
+ self.isVisible = true
+ self.panel?.orderFrontRegardless()
+ self.panel?.displayIfNeeded()
+ CATransaction.flush()
+ }
+
+ func hide() {
+ self.isVisible = false
+ self.panel?.orderOut(nil)
+ }
+
+ private var builtInScreen: NSScreen? {
+ NSScreen.screens.first {
+ guard let number = $0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber else { return false }
+ return CGDisplayIsBuiltin(number.uint32Value) != 0
+ }
+ }
+
+ private func updateFrame() {
+ // Keep the existing cover if the internal display disappears while shut.
+ if let screen = self.builtInScreen {
+ self.panel?.setFrame(screen.frame, display: true)
+ }
+ }
+
+ @objc
+ private func screensChanged() {
+ self.updateFrame()
+ if self.isVisible {
+ self.show()
+ }
+ }
+}
+
+private final class PrivacyPanel: NSPanel {
+ override var canBecomeKey: Bool {
+ false
+ }
+
+ override var canBecomeMain: Bool {
+ false
+ }
+}
diff --git a/src/Caffeine/Classes/Models/LockRevealGate.swift b/src/Caffeine/Classes/Models/LockRevealGate.swift
new file mode 100644
index 0000000..04e0265
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LockRevealGate.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+/// A logical lock flag may precede presentation of the secure screen.
+/// Keep the cover until confirmation remains stable through a settling period.
+struct LockRevealGate {
+ private var lockedSince: TimeInterval?
+ private let settlingTime: TimeInterval = 0.75
+
+ mutating func canReveal(locked: Bool?, lidClosed: Bool, now: TimeInterval) -> Bool {
+ guard locked == true, !lidClosed else { self.lockedSince = nil
+ return false
+ }
+ guard let since = self.lockedSince else { self.lockedSince = now
+ return false
+ }
+ return now - since >= self.settlingTime
+ }
+}
diff --git a/src/Caffeine/Resources/ar.lproj/Localizable.strings b/src/Caffeine/Resources/ar.lproj/Localizable.strings
index a2de611..9c954b1 100644
--- a/src/Caffeine/Resources/ar.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings
@@ -44,3 +44,5 @@
/* Minimal lid preference */
"Turns off screen and keyboard lighting when closed, and locks your Mac when opened." = "يطفئ إضاءة الشاشة والكيبورد عند إغلاق الغطاء، ويقفل جهازك عند فتحه.";
"Preparing…" = "جارٍ الإعداد…";
+
+"Waiting for macOS to confirm locking. The screen remains covered for privacy." = "بانتظار تأكيد القفل من macOS. تبقى الشاشة مغطاة لحماية الخصوصية.";
diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings
index 9bf4785..b89c50c 100644
--- a/src/Caffeine/Resources/en.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/en.lproj/Localizable.strings
@@ -61,3 +61,5 @@
/* Minimal lid preference */
"Turns off screen and keyboard lighting when closed, and locks your Mac when opened." = "Turns off screen and keyboard lighting when closed, and locks your Mac when opened.";
"Preparing…" = "Preparing…";
+
+"Waiting for macOS to confirm locking. The screen remains covered for privacy." = "Waiting for macOS to confirm locking. The screen remains covered for privacy.";
diff --git a/tests/LockRevealTests.swift b/tests/LockRevealTests.swift
new file mode 100644
index 0000000..dff8853
--- /dev/null
+++ b/tests/LockRevealTests.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+@main
+struct LockRevealTests {
+ static func main() {
+ var gate = LockRevealGate()
+ precondition(!gate.canReveal(locked: false, lidClosed: false, now: 0))
+ precondition(
+ !gate.canReveal(locked: true, lidClosed: false, now: 1),
+ "First logical lock flag cannot expose the panel"
+ )
+ precondition(!gate.canReveal(locked: true, lidClosed: false, now: 1.7), "Wait through lock-screen presentation")
+ precondition(gate.canReveal(locked: true, lidClosed: false, now: 1.75))
+
+ var flicker = LockRevealGate()
+ precondition(!flicker.canReveal(locked: true, lidClosed: false, now: 0))
+ precondition(
+ !flicker.canReveal(locked: nil, lidClosed: false, now: 0.6),
+ "Unknown lock status invalidates confirmation"
+ )
+ precondition(!flicker.canReveal(locked: true, lidClosed: false, now: 1))
+ precondition(!flicker.canReveal(locked: true, lidClosed: false, now: 1.7))
+ precondition(flicker.canReveal(locked: true, lidClosed: false, now: 1.75))
+
+ var reopened = LockRevealGate()
+ precondition(!reopened.canReveal(locked: true, lidClosed: false, now: 0))
+ precondition(!reopened.canReveal(locked: true, lidClosed: true, now: 1), "Never illuminate a reclosed lid")
+ precondition(!reopened.canReveal(locked: true, lidClosed: false, now: 2), "Reopening restarts confirmation")
+ precondition(
+ !reopened.canReveal(locked: false, lidClosed: false, now: 3),
+ "Unlock cannot satisfy the reveal gate"
+ )
+
+ var failed = LockRevealGate()
+ for time in [0.0, 1, 10, 100] {
+ precondition(
+ !failed.canReveal(locked: false, lidClosed: false, now: time),
+ "Timeout cannot reveal an unlocked desktop"
+ )
+ }
+ print("Lock reveal timing and failure regression tests passed")
+ }
+}
From 5f96dde733a0081da79dd2a1589b42edf8eb74fa Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Sun, 6 Sep 2026 07:38:06 +0300
Subject: [PATCH 5/6] Fix intermittent lid-session loss during heartbeat
renewal
---
CHANGELOG.md | 4 +-
docs/PROTECTED-LID.md | 25 +++++---
.../Classes/Models/AutomaticLidManager.swift | 4 +-
.../Classes/Models/LidHelperSession.swift | 50 ++++++++++++----
.../Classes/Models/LidLeaseHeartbeat.swift | 59 +++++++++++++++++++
.../Resources/protected-lid-watchdog.sh | 27 +++++----
tests/LidHelperSessionTests.swift | 20 ++++++-
tests/test_lid_watchdog.py | 33 ++++++++++-
8 files changed, 186 insertions(+), 36 deletions(-)
create mode 100644 src/Caffeine/Classes/Models/LidLeaseHeartbeat.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c2654a..b8ae92a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Automatic lid handling on the ordinary Caffeine toggle: darken the built-in display and keyboard without locking on closure, lock on reopening, then restore the saved brightness levels.
-- First-use administrator helper installation, reused across activations. The existing version-1 helper also supports the new automatic behavior.
+- First-use administrator helper installation, reused across activations. Version-1 installations receive a one-time administrator prompt to upgrade to the corrected version-2 helper.
- Arabic interface, universal build and helper removal scripts, heartbeat/crash recovery, battery/thermal stopping, and isolated helper/transition tests.
### Changed
@@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- Prevent intermittent lid-control deactivation when a fresh heartbeat crosses a clock-second boundary. Publish helper status atomically, renew the lease independently of the UI loop, and prevent App Nap while the session is active. Crash and stale-heartbeat cleanup remain enabled.
+
- Cover desktop pixels with an opaque black panel before lid reopening; keep forcing both brightness levels to zero until native lock confirmation stays stable. Late or unknown lock state never restores brightness on a timeout.
- Rapid off/on toggling waits for the previous helper session to finish restoring power instead of failing and opening preferences. Canceled startup attempts cannot overwrite a newer session.
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
index 61fb4f1..9531fe0 100644
--- a/docs/PROTECTED-LID.md
+++ b/docs/PROTECTED-LID.md
@@ -24,9 +24,9 @@ Manual locking and managed security policies are not overridden or undone.
Build with `bash scripts/build-lid.sh`, open `dist/Caffeine Lid.app`, and activate
Caffeine using the menu-bar cup with the **Automatic lid control** checkbox enabled. Wait for the brief
**Preparing…** message to disappear before testing the lid. First activation
-installs a limited helper using the native administrator dialog. Later activations
-need no administrator prompt. The previously installed version-1 helper is reused;
-this behavior change requires no new privileged operation or installation.
+installs a limited helper using the native administrator dialog. Version-1
+installations need administrator approval once to install the corrected version-2
+helper. Later activations reuse version 2 without another administrator prompt.
Quit other keep-awake/brightness utilities while testing. This fork has its own
bundle identifier `net.ziyad.caffeine.lid` and does not accept upstream auto-updates.
@@ -82,13 +82,21 @@ bounded data, never commands. The helper validates UID, PID/start identity, UUID
freshness and a two-step handshake, then runs fixed `pmset` operations. The wire
opcode `locked` is the historical version-1 name for **activate the power override**;
it does not ask the helper to lock the screen or attest to lock state. Keeping that
-wire format permits the one-time-installed helper to serve this updated client.
+wire format remains unchanged in version 2, which fixes heartbeat validation and
+publishes status by atomic replacement so readers cannot see a partial value.
No sudoers exception or stored password is used. This IPC authorizes the installing
user, not a specific code signature; other processes under that UID can request
this same limited power operation. Only one installing account/session is supported.
The helper normally restores sleep within one poll (about one second) after lease
-removal. A stalled client loses its lease after ten seconds. launchd restarts the
+removal. A stopped heartbeat loses its lease after ten seconds. Renewal runs once
+per second on a serial utility queue, independently of the UI loop and brightness
+calls. A scoped process activity prevents App Nap during the session. Stopping
+drains any in-flight renewal before removing the lease, so a queued callback cannot
+recreate it. The helper samples the lease timestamp before reading the clock:
+sampling in the opposite order could reject a valid concurrent renewal at a second
+boundary as future-dated. Stale and truly future-dated requests remain invalid.
+launchd restarts the
helper after a crash; a durable ownership marker restores this helper's override
before accepting new sessions, including after a reboot. An external pre-existing
SleepDisabled override is preserved and reported as a conflict. Avoid other apps
@@ -126,10 +134,13 @@ bash scripts/build-lid.sh
swiftformat .
```
-The 17 isolated watchdog/client tests cover heartbeat expiration, client/helper
+The 19 isolated watchdog/client tests cover heartbeat expiration, client/helper
crashes, failed enable/restore, conflicts and untrusted request data. The actual
Swift client is also tested against the helper for rapid off/on cycles and
-cancellation during startup; restarts wait for prior-session cleanup. The transition
+cancellation during startup; restarts wait for prior-session cleanup. A regression
+test renews between the helper's timestamp and clock reads, and another blocks the
+Swift main actor for 13 seconds (longer than the ten-second lease) while verifying
+that the background heartbeat remains active. The transition
suite covers activation without locking, close without locking, open with locking,
confirmation-before-brightness restoration, repeated events, successive cycles and
rapid reclosure while locking. Neither suite closes the physical lid or locks the
diff --git a/src/Caffeine/Classes/Models/AutomaticLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
index 174bc83..425176c 100644
--- a/src/Caffeine/Classes/Models/AutomaticLidManager.swift
+++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
@@ -116,7 +116,7 @@ final class AutomaticLidManager: ObservableObject {
)
return
}
- do { try self.helperSession.renew() } catch { self.fail()
+ do { try self.helperSession.checkHealth() } catch { self.fail()
return
}
heartbeat = Date()
@@ -243,7 +243,7 @@ final class AutomaticLidManager: ObservableObject {
private func helperIsInstalled() -> Bool {
let owner = try? String(contentsOf: self.helperDirectory.appendingPathComponent("owner"), encoding: .utf8)
let version = try? String(contentsOf: self.helperDirectory.appendingPathComponent("version"), encoding: .utf8)
- return owner == String(getuid()) && version == "1"
+ return owner == String(getuid()) && version == "2"
}
private nonisolated static func installCommand(script: String) -> String {
diff --git a/src/Caffeine/Classes/Models/LidHelperSession.swift b/src/Caffeine/Classes/Models/LidHelperSession.swift
index a014689..077e591 100644
--- a/src/Caffeine/Classes/Models/LidHelperSession.swift
+++ b/src/Caffeine/Classes/Models/LidHelperSession.swift
@@ -4,6 +4,8 @@ import Foundation
@MainActor
final class LidHelperSession {
private let directory: URL
+ private let heartbeat: LidLeaseHeartbeat
+ private var activity: NSObjectProtocol?
private var sessionID: UUID?
private var leaseURL: URL {
self.directory.appendingPathComponent("request/lease")
@@ -11,30 +13,49 @@ final class LidHelperSession {
init(directory: URL = URL(fileURLWithPath: "/Library/Application Support/CaffeineLid", isDirectory: true)) {
self.directory = directory
+ self.heartbeat = LidLeaseHeartbeat(leaseURL: directory.appendingPathComponent("request/lease"))
}
func start(id: UUID, isInstalled: () -> Bool = { true }) async throws {
+ guard self.sessionID == nil else { throw SessionError.unavailable }
self.sessionID = id
- // Off/on can happen within the helper's one-second polling interval.
- // Leave the old request removed until the helper has restored power.
- try await self.wait(id: id) {
- guard isInstalled(), let status = self.status else { return false }
- return status == "idle" || status.hasPrefix("failed:") || status.hasPrefix("conflict:")
+ self.activity = ProcessInfo.processInfo.beginActivity(
+ options: .userInitiatedAllowingIdleSystemSleep,
+ reason: "Maintain Caffeine lid-control lease"
+ )
+ do {
+ // Off/on can happen within the helper's one-second polling interval.
+ // Leave the old request removed until the helper has restored power.
+ try await self.wait(id: id) {
+ guard isInstalled(), let status = self.status else { return false }
+ return status == "idle" || status.hasPrefix("failed:") || status.hasPrefix("conflict:")
+ }
+ try self.writeRequest(active: false, id: id)
+ try await self.wait(id: id) { self.status == "ready:\(id.uuidString)" }
+ try self.writeRequest(active: true, id: id)
+ try await self.wait(id: id) { self.status == "active:\(id.uuidString)" }
+ try self.heartbeat.start(value: self.request(active: true, id: id))
+ } catch {
+ if self.sessionID == id {
+ self.stop()
+ }
+ throw error
}
- try self.writeRequest(active: false, id: id)
- try await self.wait(id: id) { self.status == "ready:\(id.uuidString)" }
- try self.writeRequest(active: true, id: id)
- try await self.wait(id: id) { self.status == "active:\(id.uuidString)" }
}
- func renew() throws {
+ func checkHealth() throws {
+ try self.heartbeat.checkHealth()
guard let id = self.sessionID, self.status == "active:\(id.uuidString)" else { throw SessionError.unavailable }
- try self.writeRequest(active: true, id: id)
}
func stop() {
let oldID = self.sessionID
self.sessionID = nil
+ self.heartbeat.stop()
+ if let activity = self.activity {
+ ProcessInfo.processInfo.endActivity(activity)
+ self.activity = nil
+ }
if
let oldID, let value = try? String(contentsOf: self.leaseURL, encoding: .utf8),
value.hasSuffix(":" + oldID.uuidString)
@@ -49,9 +70,12 @@ final class LidHelperSession {
private func writeRequest(active: Bool, id: UUID) throws {
guard self.sessionID == id, !Task.isCancelled else { throw CancellationError() }
+ try self.request(active: active, id: id).write(to: self.leaseURL, atomically: true, encoding: .utf8)
+ }
+
+ private func request(active: Bool, id: UUID) -> String {
// Keep the installed version-1 helper's historical activation opcode.
- let value = "\(active ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(id.uuidString)"
- try value.write(to: self.leaseURL, atomically: true, encoding: .utf8)
+ "\(active ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(id.uuidString)"
}
private func wait(id: UUID, until ready: () -> Bool) async throws {
diff --git a/src/Caffeine/Classes/Models/LidLeaseHeartbeat.swift b/src/Caffeine/Classes/Models/LidLeaseHeartbeat.swift
new file mode 100644
index 0000000..d30fa86
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LidLeaseHeartbeat.swift
@@ -0,0 +1,59 @@
+import Foundation
+
+/// All mutable state and lease writes are serialized on this queue. Renewal
+/// must not wait for AppKit, brightness calls, or the main actor to respond.
+final class LidLeaseHeartbeat: @unchecked Sendable {
+ private let queue = DispatchQueue(label: "net.ziyad.caffeine.lid-heartbeat", qos: .utility)
+ private let leaseURL: URL
+ private var timer: DispatchSourceTimer?
+ private var failure: Error?
+
+ init(leaseURL: URL) {
+ self.leaseURL = leaseURL
+ }
+
+ func start(value: String) throws {
+ try self.queue.sync {
+ self.timer?.cancel()
+ self.timer = nil
+ self.failure = nil
+ try value.write(to: self.leaseURL, atomically: true, encoding: .utf8)
+ let timer = DispatchSource.makeTimerSource(queue: self.queue)
+ timer.schedule(deadline: .now() + 1, repeating: 1, leeway: .milliseconds(100))
+ timer.setEventHandler { [weak self, weak timer] in
+ guard let self, let timer, self.timer === timer else { return }
+ do {
+ try value.write(to: self.leaseURL, atomically: true, encoding: .utf8)
+ } catch {
+ self.failure = error
+ timer.cancel()
+ self.timer = nil
+ }
+ }
+ self.timer = timer
+ timer.resume()
+ }
+ }
+
+ func checkHealth() throws {
+ try self.queue.sync {
+ if let failure = self.failure {
+ throw failure
+ }
+ }
+ }
+
+ func stop() {
+ // Drain any in-flight write before the caller removes the lease. A
+ // queued event must never recreate a stopped or newer session's lease.
+ self.queue.sync {
+ self.timer?.cancel()
+ self.timer = nil
+ self.failure = nil
+ }
+ }
+
+ deinit {
+ self.timer?.cancel()
+ }
+}
diff --git a/src/Caffeine/Resources/protected-lid-watchdog.sh b/src/Caffeine/Resources/protected-lid-watchdog.sh
index 81d4a91..ae3b16a 100644
--- a/src/Caffeine/Resources/protected-lid-watchdog.sh
+++ b/src/Caffeine/Resources/protected-lid-watchdog.sh
@@ -11,6 +11,11 @@ lease="$state/request/lease"
umask 022
changed=0
nonce=''
+set_status() {
+ # Clients must never observe a truncated/partially written status.
+ printf '%s' "$1" > "$state/status.next"
+ /bin/mv -f "$state/status.next" "$state/status"
+}
restore() {
if [ "$changed" = 1 ] || [ -f "$state/owned" ]; then
until /usr/bin/pmset -a disablesleep 0; do /bin/sleep 2; done
@@ -21,7 +26,7 @@ restore() {
cleanup() {
trap '' HUP INT TERM
restore
- printf 'offline' > "$state/status"
+ set_status offline
}
trap cleanup EXIT
trap 'exit 0' HUP INT TERM
@@ -29,8 +34,8 @@ trap 'exit 0' HUP INT TERM
# machine restarts. Never restore a power override that this helper didn't own.
restore
printf '%s' "$owner_uid" > "$state/owner"
-printf '1' > "$state/version"
-printf 'idle' > "$state/status"
+printf '2' > "$state/version"
+set_status idle
phase=idle
last_nonce=''
while :; do
@@ -48,8 +53,10 @@ while :; do
case "$pid" in ''|*[!0-9]*) valid=0 ;; esac
case "$incoming_nonce" in ''|*[!A-Fa-f0-9-]*) valid=0 ;; esac
[ "${#incoming_nonce}" = 36 ] || valid=0
- now=$(/bin/date +%s)
+ # Sample the lease before the clock. A concurrent atomic renewal can cross
+ # a second boundary; reading date first falsely rejects it as future-dated.
modified=$(/usr/bin/stat -f %m "$lease" 2>/dev/null) || modified=0
+ now=$(/bin/date +%s)
if [ "$((now - modified))" -gt 10 ] || [ "$modified" -gt "$now" ]; then valid=0; fi
if [ "$valid" = 1 ]; then
user=$(/bin/ps -p "$pid" -o uid= | /usr/bin/tr -d ' ') || user=''
@@ -63,7 +70,7 @@ while :; do
{ [ "$phase" = ready ] && [ "$((now - started))" -gt 30 ]; }; then
restore
phase=idle
- printf 'idle' > "$state/status"
+ set_status idle
fi
fi
if [ "$phase" = idle ] && [ "$valid" = 1 ] &&
@@ -76,9 +83,9 @@ while :; do
identity=$(/bin/ps -p "$pid" -o lstart=) || identity=''
started=$now
phase=ready
- printf 'ready:%s' "$nonce" > "$state/status"
+ set_status "ready:$nonce"
else
- printf 'conflict:%s' "$nonce" > "$state/status"
+ set_status "conflict:$nonce"
fi
elif [ "$phase" = ready ] && [ "$valid" = 1 ] && [ "$kind" = locked ]; then
# Recheck after the lock handshake: another app may have changed the
@@ -86,7 +93,7 @@ while :; do
before=$(/usr/bin/pmset -g | /usr/bin/awk '$1 == "SleepDisabled" {print $2}') || before=''
if [ "$before" != 0 ]; then
phase=idle
- printf 'conflict:%s' "$nonce" > "$state/status"
+ set_status "conflict:$nonce"
/bin/sleep 1
continue
fi
@@ -99,11 +106,11 @@ while :; do
fi
if [ "$current" = 1 ]; then
phase=active
- printf 'active:%s' "$nonce" > "$state/status"
+ set_status "active:$nonce"
else
restore
phase=idle
- printf 'failed:%s' "$nonce" > "$state/status"
+ set_status "failed:$nonce"
fi
fi
/bin/sleep 1
diff --git a/tests/LidHelperSessionTests.swift b/tests/LidHelperSessionTests.swift
index 3753c22..017559a 100644
--- a/tests/LidHelperSessionTests.swift
+++ b/tests/LidHelperSessionTests.swift
@@ -4,17 +4,33 @@ import Foundation
/// power commands. No display, keyboard, locking, or installed helper is touched.
@main
struct LidHelperSessionTests {
+ static func blockMainActor() {
+ Thread.sleep(forTimeInterval: 13)
+ }
+
@MainActor
static func main() async throws {
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let client = LidHelperSession(directory: directory)
for _ in 0..<4 {
try await client.start(id: UUID())
- try client.renew()
+ try client.checkHealth()
client.stop()
// Deliberately no delay: the helper still reports the old session.
}
+ if CommandLine.arguments.count > 2 {
+ try await client.start(id: UUID())
+ // Intentionally block the main actor longer than the helper lease.
+ // Renewal must continue independently of UI execution.
+ self.blockMainActor()
+ try client.checkHealth()
+ client.stop()
+ try await Task.sleep(for: .seconds(2))
+ precondition(!FileManager.default
+ .fileExists(atPath: directory.appendingPathComponent("request/lease").path))
+ }
+
let cancelledID = UUID()
let cancelledStart = Task { try await client.start(id: cancelledID) }
try await Task.sleep(for: .milliseconds(10))
@@ -27,7 +43,7 @@ struct LidHelperSessionTests {
} catch is CancellationError {
// Expected: the previous continuation cannot write the new lease.
}
- try client.renew()
+ try client.checkHealth()
let lease = try String(contentsOf: directory.appendingPathComponent("request/lease"), encoding: .utf8)
precondition(lease.hasSuffix(newID.uuidString), "Stale startup replaced the current lease")
client.stop()
diff --git a/tests/test_lid_watchdog.py b/tests/test_lid_watchdog.py
index 9d18886..9459823 100644
--- a/tests/test_lid_watchdog.py
+++ b/tests/test_lid_watchdog.py
@@ -100,6 +100,7 @@ def test_swift_client_rapid_off_on_and_cancelled_start(self):
executable = self.root / "session-tests"
subprocess.run([
"swiftc", str(repo / "src/Caffeine/Classes/Models/LidHelperSession.swift"),
+ str(repo / "src/Caffeine/Classes/Models/LidLeaseHeartbeat.swift"),
str(repo / "tests/LidHelperSessionTests.swift"), "-o", str(executable),
], check=True, capture_output=True, text=True)
helper = subprocess.Popen(
@@ -108,11 +109,35 @@ def test_swift_client_rapid_off_on_and_cancelled_start(self):
)
self.processes.append(helper)
self.wait_for(lambda: self.status() == "idle")
- result = subprocess.run([str(executable), str(self.state)], capture_output=True, text=True, timeout=45)
+ result = subprocess.run([str(executable), str(self.state), "block-main-actor"], capture_output=True, text=True, timeout=65)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("regression tests passed", result.stdout)
self.assert_restored(helper)
+ def test_renewal_between_clock_and_timestamp_reads_stays_active(self):
+ # Emulate a legitimate renewal crossing a second boundary after date
+ # sampled the clock but before the old helper reads the lease timestamp.
+ import shlex
+ trigger = self.root / "renew-during-clock-read"
+ clock = self.root / "clock"
+ clock.write_text(f'''#!/bin/sh
+now=$(/bin/date +%s)
+if [ -f {shlex.quote(str(trigger))} ]; then
+ /usr/bin/touch -t "$(/bin/date -r "$((now + 1))" +%Y%m%d%H%M.%S)" {shlex.quote(str(self.lease))}
+fi
+printf '%s\\n' "$now"
+''')
+ clock.chmod(0o700)
+ self.script = self.script.replace("/bin/date +%s", shlex.quote(str(clock)))
+ process = self.active()
+ trigger.touch()
+ time.sleep(4)
+ self.assertEqual(self.status(), f"active:{NONCE}")
+ self.assertEqual(self.calls.read_text().splitlines(), ["-a disablesleep 1"])
+ trigger.unlink()
+ self.lease.unlink()
+ self.assert_restored(process)
+
def test_pending_does_not_change_power(self):
process = self.launch()
self.wait_for(lambda: self.status() == f"ready:{NONCE}")
@@ -211,6 +236,12 @@ def test_stale_lease_never_enables_sleep_override(self):
self.assert_restored(process)
self.assertEqual(self.calls.read_text(), "")
+ def test_future_dated_heartbeat_restores_sleep(self):
+ process = self.active()
+ future = time.time() + 60
+ os.utime(self.lease, (future, future))
+ self.assert_restored(process)
+
def test_invalid_pid_is_never_executed(self):
self.launch(pid="1; touch /tmp/should-not-exist")
time.sleep(1.5)
From c48e2ea32833ba712de50ed2d5280c9c2ccd9345 Mon Sep 17 00:00:00 2001
From: ziyad-aljohani <135967503+ziyad-aljohani@users.noreply.github.com>
Date: Mon, 7 Sep 2026 13:17:48 +0300
Subject: [PATCH 6/6] Pause quietly for low battery and resume on charger
connection
---
CHANGELOG.md | 4 +-
docs/PROTECTED-LID.md | 30 +++-
.../Classes/Models/AutomaticLidManager.swift | 49 ++----
.../Classes/Models/PowerPauseState.swift | 41 +++++
.../Classes/Models/PowerSnapshot+System.swift | 26 +++
.../Classes/Models/PowerSourceMonitor.swift | 40 +++++
.../ViewModels/CaffeineViewModel.swift | 80 +++++++--
.../Classes/Views/MenuBarController.swift | 11 +-
.../Classes/Views/PreferencesView.swift | 9 +
.../Resources/ar.lproj/Localizable.strings | 5 +-
.../Resources/en.lproj/Localizable.strings | 5 +-
tests/PowerPauseTests.swift | 160 ++++++++++++++++++
12 files changed, 401 insertions(+), 59 deletions(-)
create mode 100644 src/Caffeine/Classes/Models/PowerPauseState.swift
create mode 100644 src/Caffeine/Classes/Models/PowerSnapshot+System.swift
create mode 100644 src/Caffeine/Classes/Models/PowerSourceMonitor.swift
create mode 100644 tests/PowerPauseTests.swift
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b8ae92a..04b64ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,10 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Automatic lid handling on the ordinary Caffeine toggle: darken the built-in display and keyboard without locking on closure, lock on reopening, then restore the saved brightness levels.
- First-use administrator helper installation, reused across activations. Version-1 installations receive a one-time administrator prompt to upgrade to the corrected version-2 helper.
-- Arabic interface, universal build and helper removal scripts, heartbeat/crash recovery, battery/thermal stopping, and isolated helper/transition tests.
+- Arabic interface, universal build and helper removal scripts, heartbeat/crash recovery, battery/thermal pauses, and isolated helper/transition tests.
### Changed
+- Pause Caffeine quietly at 10% battery or less while unplugged and resume automatically on external power. Show the specific pause reason in the menu and preferences instead of opening preferences as an error. Thermal pauses resume after cooling; manual deactivation and expired timers cancel automatic resume.
+
- Replaced the large lid-control panel with one persistent checkbox and a short description; enabled by default.
- Replaced the earlier lock-at-activation session button with automatic behavior. Activation no longer locks; unlocking no longer ends Caffeine.
diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md
index 9531fe0..0a7fbc2 100644
--- a/docs/PROTECTED-LID.md
+++ b/docs/PROTECTED-LID.md
@@ -13,6 +13,8 @@ separate session button, and activation never requests a lock.
| Close the lid | Set built-in screen brightness and keyboard backlight to zero; remain awake without requesting a lock |
| Open the lid | Request the native macOS lock immediately, then restore both saved brightness levels after stable lock confirmation |
| Authenticate | Caffeine remains active; the next lid cycle works automatically |
+| Battery reaches 10% or less while unplugged | Pause sleep prevention and app-activity simulation quietly; wait for external power |
+| Connect the charger | Resume the enabled session automatically, unless it expired, was switched off, or the Mac is still too hot |
| Deactivate, timer expires, or quit | Release the helper lease and restore ordinary sleep/brightness |
The user's existing Mac login credentials apply; Caffeine never creates, reads,
@@ -102,7 +104,25 @@ before accepting new sessions, including after a reboot. An external pre-existin
SleepDisabled override is preserved and reported as a conflict. Avoid other apps
that change the same global setting mid-session, as macOS exposes no per-app ownership.
-The app stops at 10% battery while unplugged or a serious/critical thermal state.
+The app pauses at 10% battery or less while unplugged. It releases the power
+override, ordinary sleep assertion, and activity simulation without opening
+preferences or reporting an error. Caffeine remains enabled, with an inactive cup
+icon and a specific pause reason in its menu/tooltip and, if opened manually,
+preferences. A low-battery pause waits for external power, even if the battery
+percentage fluctuates above 10%; charging resumes the session even below 10%.
+Power-source notifications detect charger changes, with a two-second polling
+fallback while awake and another check on wake. A sleeping Mac
+must wake before Caffeine can observe the charger and resume.
+
+Serious/critical thermal pressure has its own overheating message and pauses until
+macOS reports cooling, including on external power. If both conditions apply, the
+thermal message takes priority; cooling without AC then shows the battery reason.
+Manual deactivation or expiration of the original timer cancels automatic resume.
+Power pauses apply to Caffeine's sleep prevention and app activity with or without
+automatic lid control. A pause during a protected closed-lid cycle retains its
+black cover and lid-open lock handling until locking is confirmed; it does not
+restore an unlocked desktop while releasing the power override.
+
Brightness zero reduces lighting power; the running CPU still consumes more energy
than sleep. Do not run the laptop in a closed bag; keep ventilation clear.
@@ -126,6 +146,8 @@ approval; ordinary toggling does not.
```sh
python3 -m unittest discover -s tests -v
+swiftc -target "$(uname -m)-apple-macosx14.6" src/Caffeine/Classes/Models/PowerPauseState.swift src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift tests/PowerPauseTests.swift -o /tmp/caffeine-power-tests
+/tmp/caffeine-power-tests
swiftc src/Caffeine/Classes/Models/LockRevealGate.swift tests/LockRevealTests.swift -o /tmp/caffeine-reveal-tests
/tmp/caffeine-reveal-tests
swiftc src/Caffeine/Classes/Models/LidCycleState.swift tests/LidCycleTests.swift -o /tmp/caffeine-lid-tests
@@ -155,6 +177,12 @@ the former brightness levels return. Hardware checks cannot be substituted by a
successful build. New strings are localized in Arabic and English; other languages
fall back to English for this feature.
+The power-pause suite exercises the real view model with inert hardware adapters:
+quiet low-battery activation, threshold crossing, charging below the threshold,
+unknown/fluctuating battery readings, distinct thermal reasons, wake handling,
+manual cancellation, expired timers, and a queued-start/pause race. No real battery
+drain, charger changes, screen locking, or installed preferences are used by tests.
+
## References
- [Apple pmset implementation](https://github.com/apple-oss-distributions/PowerManagement/blob/main/pmset/pmset.m)
diff --git a/src/Caffeine/Classes/Models/AutomaticLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
index 425176c..d632492 100644
--- a/src/Caffeine/Classes/Models/AutomaticLidManager.swift
+++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift
@@ -1,7 +1,6 @@
import AppKit
import Combine
import IOKit
-import IOKit.ps
import IOKit.pwr_mgt
/// Watches every lid cycle while Caffeine is active. Closing only darkens the
@@ -56,7 +55,7 @@ final class AutomaticLidManager: ObservableObject {
self.isPreparing = true
// Monitor before enabling the override: no close/open transition during
// administrator approval or helper startup is silently discarded.
- if !self.cycle.lockPending {
+ if self.lidMonitor == nil, !self.cycle.lockPending {
self.cycle = LidCycleState()
}
let watcher = LidMonitor { [weak self] closed in self?.handleLid(closed) }
@@ -64,6 +63,7 @@ final class AutomaticLidManager: ObservableObject {
self.fail(String(localized: "Automatic lid control is unavailable on this Mac. Caffeine was deactivated."))
return
}
+ self.lidMonitor?.stop()
self.lidMonitor = watcher
self.handleLid(closed)
do {
@@ -107,15 +107,6 @@ final class AutomaticLidManager: ObservableObject {
self.display.rememberBrightness()
self.keyboard.rememberBrightness()
}
- if Self.shouldStopForPower() {
- self
- .fail(
- String(
- localized: "Caffeine stopped because the battery is low or the Mac is too warm."
- )
- )
- return
- }
do { try self.helperSession.checkHealth() } catch { self.fail()
return
}
@@ -129,12 +120,15 @@ final class AutomaticLidManager: ObservableObject {
}
}
- func stop() {
+ func stop(preservingLidPrivacy: Bool = false) {
+ let keepPrivacy = preservingLidPrivacy && (self.cycle.closed || self.cycle.lockPending)
self.sessionID = nil
self.monitor?.cancel()
self.monitor = nil
- self.lidMonitor?.stop()
- self.lidMonitor = nil
+ if !keepPrivacy {
+ self.lidMonitor?.stop()
+ self.lidMonitor = nil
+ }
self.helperSession.stop()
self.isPreparing = false
self.isRunning = false
@@ -144,7 +138,7 @@ final class AutomaticLidManager: ObservableObject {
}
// A lock already requested on opening must finish before making the
// desktop visible, even when a timeout/stop races with that opening.
- if !self.cycle.lockPending || self.cycle.closed {
+ if !keepPrivacy, !self.cycle.lockPending || self.cycle.closed {
self.lockTask?.cancel()
self.lockRequestID = nil
self.restoreLights()
@@ -188,6 +182,10 @@ final class AutomaticLidManager: ObservableObject {
{
if self.cycle.confirmLock() {
self.restoreLights()
+ if !self.isEngaged {
+ self.lidMonitor?.stop()
+ self.lidMonitor = nil
+ }
}
self.lockRequestID = nil
return
@@ -308,26 +306,5 @@ final class AutomaticLidManager: ObservableObject {
return session["CGSSessionScreenIsLocked"] as? Bool
}
- private static func shouldStopForPower() -> Bool {
- if [.serious, .critical].contains(ProcessInfo.processInfo.thermalState) {
- return true
- }
- guard
- let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
- let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] else { return false }
- for source in sources {
- guard
- let values = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() as? [String: Any],
- values[kIOPSTransportTypeKey] as? String == kIOPSInternalType,
- values[kIOPSPowerSourceStateKey] as? String == kIOPSBatteryPowerValue,
- let current = values[kIOPSCurrentCapacityKey] as? Int,
- let maximum = values[kIOPSMaxCapacityKey] as? Int, maximum > 0 else { continue }
- if Double(current) / Double(maximum) <= 0.10 {
- return true
- }
- }
- return false
- }
-
private enum LidError: Error { case unavailable }
}
diff --git a/src/Caffeine/Classes/Models/PowerPauseState.swift b/src/Caffeine/Classes/Models/PowerPauseState.swift
new file mode 100644
index 0000000..3ae6b57
--- /dev/null
+++ b/src/Caffeine/Classes/Models/PowerPauseState.swift
@@ -0,0 +1,41 @@
+import Foundation
+
+struct PowerSnapshot {
+ var onExternalPower: Bool?
+ var batteryFraction: Double?
+ var isTooHot: Bool
+}
+
+enum PowerPauseReason: Equatable {
+ case lowBattery, overheating
+
+ var message: String {
+ switch self {
+ case .lowBattery:
+ String(localized: "Paused for low battery. Connect the charger to resume.")
+ case .overheating:
+ String(localized: "Paused because the Mac is too hot. Resumes after it cools down.")
+ }
+ }
+}
+
+/// Low battery stays latched until AC is actually detected; a fluctuating or
+/// unavailable battery reading must not repeatedly restart the sleep override.
+struct PowerPauseState {
+ private var waitingForCharger = false
+
+ mutating func update(_ snapshot: PowerSnapshot) -> PowerPauseReason? {
+ if snapshot.onExternalPower == true {
+ self.waitingForCharger = false
+ } else if
+ snapshot.onExternalPower == false,
+ let fraction = snapshot.batteryFraction, fraction <= 0.10
+ {
+ self.waitingForCharger = true
+ }
+ if snapshot.isTooHot {
+ return .overheating
+ }
+ return self.waitingForCharger ? .lowBattery : nil
+ }
+}
diff --git a/src/Caffeine/Classes/Models/PowerSnapshot+System.swift b/src/Caffeine/Classes/Models/PowerSnapshot+System.swift
new file mode 100644
index 0000000..11526dc
--- /dev/null
+++ b/src/Caffeine/Classes/Models/PowerSnapshot+System.swift
@@ -0,0 +1,26 @@
+import Foundation
+import IOKit.ps
+
+extension PowerSnapshot {
+ static func current() -> PowerSnapshot {
+ var snapshot = PowerSnapshot(
+ onExternalPower: nil, batteryFraction: nil,
+ isTooHot: [.serious, .critical].contains(ProcessInfo.processInfo.thermalState)
+ )
+ guard let info = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else { return snapshot }
+ if let source = IOPSGetProvidingPowerSourceType(info)?.takeUnretainedValue() {
+ snapshot.onExternalPower = source as String == kIOPSACPowerValue
+ }
+ guard let sources = IOPSCopyPowerSourcesList(info)?.takeRetainedValue() as? [CFTypeRef] else { return snapshot }
+ for source in sources {
+ guard
+ let values = IOPSGetPowerSourceDescription(info, source)?.takeUnretainedValue() as? [String: Any],
+ values[kIOPSTransportTypeKey] as? String == kIOPSInternalType,
+ let current = values[kIOPSCurrentCapacityKey] as? Int,
+ let maximum = values[kIOPSMaxCapacityKey] as? Int, maximum > 0 else { continue }
+ snapshot.batteryFraction = Double(current) / Double(maximum)
+ break
+ }
+ return snapshot
+ }
+}
diff --git a/src/Caffeine/Classes/Models/PowerSourceMonitor.swift b/src/Caffeine/Classes/Models/PowerSourceMonitor.swift
new file mode 100644
index 0000000..f17962a
--- /dev/null
+++ b/src/Caffeine/Classes/Models/PowerSourceMonitor.swift
@@ -0,0 +1,40 @@
+import Foundation
+import IOKit.ps
+
+/// Power-source notifications wake an otherwise idle app when AC changes.
+/// The periodic check remains a fallback and also observes thermal changes.
+@MainActor
+final class PowerSourceMonitor {
+ private let changed: () -> Void
+ private var source: CFRunLoopSource?
+
+ init(changed: @escaping () -> Void) {
+ self.changed = changed
+ }
+
+ func start() {
+ guard self.source == nil else { return }
+ self.source = IOPSNotificationCreateRunLoopSource({ context in
+ guard let context else { return }
+ MainActor.assumeIsolated {
+ Unmanaged.fromOpaque(context).takeUnretainedValue().changed()
+ }
+ }, Unmanaged.passUnretained(self).toOpaque())?.takeRetainedValue()
+ if let source = self.source {
+ CFRunLoopAddSource(CFRunLoopGetMain(), source, .commonModes)
+ }
+ }
+
+ func stop() {
+ if let source = self.source {
+ CFRunLoopSourceInvalidate(source)
+ self.source = nil
+ }
+ }
+
+ deinit {
+ if let source = self.source {
+ CFRunLoopSourceInvalidate(source)
+ }
+ }
+}
diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
index 0ecdb8f..cfc3271 100644
--- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
+++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
@@ -18,16 +18,25 @@ class CaffeineViewModel: ObservableObject {
@Published var timeRemaining: TimeInterval?
@Published var showPreferences = false
let automaticLid = AutomaticLidManager()
+ @Published private(set) var pauseReason: PowerPauseReason?
+ var isPaused: Bool {
+ self.pauseReason != nil
+ }
// MARK: - Private Properties
+ private let readPower: () -> PowerSnapshot
+ private var powerPolicy = PowerPauseState()
+ private var powerMonitor: Task?
+ private lazy var powerSourceMonitor = PowerSourceMonitor { [weak self] in self?.refreshPowerState() }
private var timeoutTimer: Timer?
private var displayTimer: Timer?
private var cancellables = Set()
// MARK: - Initialization
- init() {
+ init(readPower: @escaping () -> PowerSnapshot = { PowerSnapshot.current() }) {
+ self.readPower = readPower
// Explicitly ensure we start inactive
self.isActive = false
self.timeRemaining = nil
@@ -114,12 +123,47 @@ class CaffeineViewModel: ObservableObject {
}
self.isActive = true
+ self.refreshPowerState(force: true)
+ guard self.isActive else { return }
+ self.powerSourceMonitor.start()
+ if self.powerMonitor == nil {
+ self.powerMonitor = Task { [weak self] in
+ while !Task.isCancelled {
+ do { try await Task.sleep(for: .seconds(2)) } catch { return }
+ guard let self, self.isActive else { return }
+ self.refreshPowerState()
+ }
+ }
+ }
+ }
+
+ func refreshPowerState(force: Bool = false) {
+ guard self.isActive else { return }
+ // A paused session keeps its original deadline, including across sleep.
+ if let timer = self.timeoutTimer, timer.fireDate <= Date() {
+ self.deactivate()
+ return
+ }
+ let reason = self.powerPolicy.update(self.readPower())
+ guard force || reason != self.pauseReason else { return }
+ self.pauseReason = reason
+ self.applyActivationState()
+ }
+
+ private func applyActivationState() {
+ guard self.isActive else { return }
+ if self.isPaused {
+ self.automaticLid.stop(preservingLidPrivacy: true)
+ SleepPreventionManager.shared.allowSleep()
+ ActivitySimulator.shared.stopMonitoring()
+ return
+ }
SleepPreventionManager.shared.preventSleep()
self.updateActivitySimulation(enabled: UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive))
if UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl), !self.automaticLid.isEngaged {
Task { [weak self] in
guard
- let self, self.isActive,
+ let self, self.isActive, !self.isPaused,
UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) else { return }
await self.automaticLid.start()
}
@@ -131,6 +175,11 @@ class CaffeineViewModel: ObservableObject {
self.cancelTimers()
self.timeRemaining = nil
self.isActive = false
+ self.powerSourceMonitor.stop()
+ self.powerMonitor?.cancel()
+ self.powerMonitor = nil
+ self.powerPolicy = PowerPauseState()
+ self.pauseReason = nil
self.automaticLid.stop()
SleepPreventionManager.shared.allowSleep()
ActivitySimulator.shared.stopMonitoring()
@@ -138,28 +187,22 @@ class CaffeineViewModel: ObservableObject {
func updateAutomaticLidControl(enabled: Bool) {
UserDefaults.standard.set(enabled, forKey: PreferenceKeys.automaticLidControl)
- if enabled, self.isActive {
- Task { [weak self] in
- guard
- let self, self.isActive,
- UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) else { return }
- await self.automaticLid.start()
- }
- } else {
+ if !enabled {
self.automaticLid.stop()
self.automaticLid.errorMessage = nil
}
+ self.refreshPowerState(force: true)
}
/// Updates activity simulation based on preference
func updateActivitySimulation(enabled: Bool) {
- if enabled, !self.automaticLid.isLidClosed {
+ if enabled, !self.isPaused, !self.automaticLid.isLidClosed {
// Trigger the Accessibility permission prompt by posting a no-op event
// This prompts for "Events" permission which CGEvent.post requires
ActivitySimulator.shared.requestPermission()
}
- if enabled, self.isActive, !self.automaticLid.isLidClosed {
+ if enabled, self.isActive, !self.isPaused, !self.automaticLid.isLidClosed {
ActivitySimulator.shared.startMonitoring()
} else {
ActivitySimulator.shared.stopMonitoring()
@@ -174,6 +217,9 @@ class CaffeineViewModel: ObservableObject {
}
// If there's time remaining, format it
+ if let reason = self.pauseReason {
+ return reason.message
+ }
if let remaining = timeRemaining, remaining > 0 {
let seconds = Int(remaining)
@@ -202,7 +248,10 @@ class CaffeineViewModel: ObservableObject {
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.willSleepNotification)
.sink { [weak self] _ in
Task { @MainActor in
- if UserDefaults.standard.bool(forKey: PreferenceKeys.deactivateOnManualSleep) {
+ if
+ self?.isPaused == false,
+ UserDefaults.standard.bool(forKey: PreferenceKeys.deactivateOnManualSleep)
+ {
self?.deactivate()
}
}
@@ -214,10 +263,7 @@ class CaffeineViewModel: ObservableObject {
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didWakeNotification)
.sink { [weak self] _ in
Task { @MainActor in
- guard let self, let timeoutTimer = self.timeoutTimer else { return }
- if timeoutTimer.fireDate.timeIntervalSinceNow <= 0 {
- self.deactivate()
- }
+ self?.refreshPowerState()
}
}
.store(in: &self.cancellables)
diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift
index 8460537..db46506 100644
--- a/src/Caffeine/Classes/Views/MenuBarController.swift
+++ b/src/Caffeine/Classes/Views/MenuBarController.swift
@@ -58,6 +58,12 @@ class MenuBarController: NSObject {
}
.store(in: &self.cancellables)
+ self.viewModel.$pauseReason
+ .sink { [weak self] _ in
+ DispatchQueue.main.async { self?.updateIcon() }
+ }
+ .store(in: &self.cancellables)
+
self.viewModel.$showPreferences
.sink { [weak self] show in
if show {
@@ -70,7 +76,8 @@ class MenuBarController: NSObject {
private func updateIcon() {
guard let button = statusItem?.button else { return }
- let imageName = self.viewModel.isActive ? "active" : "inactive"
+ button.toolTip = self.viewModel.pauseReason?.message
+ let imageName = self.viewModel.isActive && !self.viewModel.isPaused ? "active" : "inactive"
if let image = NSImage(named: NSImage.Name(imageName)) {
button.image = image
}
@@ -98,7 +105,7 @@ class MenuBarController: NSObject {
menu.addItem(NSMenuItem.separator())
}
- if UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) {
+ if !self.viewModel.isPaused, UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) {
let lidItem = NSMenuItem(
title: self.viewModel.automaticLid.isPreparing
? String(localized: "Preparing automatic lid control…")
diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift
index a085181..0dd5bbb 100644
--- a/src/Caffeine/Classes/Views/PreferencesView.swift
+++ b/src/Caffeine/Classes/Views/PreferencesView.swift
@@ -107,6 +107,15 @@ struct PreferencesView: View {
}
))
+ if let reason = self.viewModel.pauseReason {
+ Text(reason.message)
+ .font(.system(size: 11))
+ .foregroundColor(.secondary)
+ .padding(.top, 8)
+ .padding(.leading, 20)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
Spacer()
.frame(height: 30)
diff --git a/src/Caffeine/Resources/ar.lproj/Localizable.strings b/src/Caffeine/Resources/ar.lproj/Localizable.strings
index 9c954b1..8acc9b3 100644
--- a/src/Caffeine/Resources/ar.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings
@@ -29,7 +29,6 @@
"Caffeine prevents sleep" = "Caffeine يمنع السكون";
"Automatic lid control is unavailable on this Mac. Caffeine was deactivated." = "التحكم التلقائي بالغطاء غير متاح على هذا الجهاز. تم إيقاف Caffeine.";
"Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work." = "التحكم بإضاءة الكيبورد غير متاح على هذا الجهاز؛ سيستمر التحكم التلقائي بالشاشة والغطاء.";
-"Caffeine stopped because the battery is low or the Mac is too warm." = "توقف Caffeine بسبب انخفاض البطارية أو ارتفاع حرارة الجهاز.";
"macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display." = "لم يؤكد macOS قفل الجهاز، لذا بقيت الشاشة مظلمة. استخدم مفاتيح السطوع إذا احتجت إلى استعادة إضاءة الشاشة.";
"Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again." = "تعذر تشغيل التحكم التلقائي بالغطاء أو توقف عن الاستجابة، فتم إيقاف Caffeine. تحقق من طلب صلاحية المسؤول ومن التطبيقات الأخرى التي تتحكم بالسكون، ثم أعد المحاولة.";
"Automatic lid control" = "التحكم التلقائي بالغطاء";
@@ -46,3 +45,7 @@
"Preparing…" = "جارٍ الإعداد…";
"Waiting for macOS to confirm locking. The screen remains covered for privacy." = "بانتظار تأكيد القفل من macOS. تبقى الشاشة مغطاة لحماية الخصوصية.";
+
+/* Quiet power pauses */
+"Paused for low battery. Connect the charger to resume." = "متوقف مؤقتًا لانخفاض البطارية. وصّل الشاحن للاستئناف تلقائيًا.";
+"Paused because the Mac is too hot. Resumes after it cools down." = "متوقف مؤقتًا بسبب ارتفاع حرارة الجهاز. يُستأنف تلقائيًا بعد أن يبرد.";
diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings
index b89c50c..b23b657 100644
--- a/src/Caffeine/Resources/en.lproj/Localizable.strings
+++ b/src/Caffeine/Resources/en.lproj/Localizable.strings
@@ -46,7 +46,6 @@
/* Automatic lid control */
"Automatic lid control is unavailable on this Mac. Caffeine was deactivated." = "Automatic lid control is unavailable on this Mac. Caffeine was deactivated.";
"Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work." = "Keyboard backlight control is unavailable on this Mac; automatic display and lid control will still work.";
-"Caffeine stopped because the battery is low or the Mac is too warm." = "Caffeine stopped because the battery is low or the Mac is too warm.";
"macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display." = "macOS did not confirm the lock. The screen remains dark. Use the brightness keys if you need to recover the display.";
"Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again." = "Automatic lid control could not start or stopped responding. Caffeine was deactivated. Check the administrator request and other sleep-control apps, then try again.";
"Automatic lid control" = "Automatic lid control";
@@ -63,3 +62,7 @@
"Preparing…" = "Preparing…";
"Waiting for macOS to confirm locking. The screen remains covered for privacy." = "Waiting for macOS to confirm locking. The screen remains covered for privacy.";
+
+/* Quiet power pauses */
+"Paused for low battery. Connect the charger to resume." = "Paused for low battery. Connect the charger to resume.";
+"Paused because the Mac is too hot. Resumes after it cools down." = "Paused because the Mac is too hot. Resumes after it cools down.";
diff --git a/tests/PowerPauseTests.swift b/tests/PowerPauseTests.swift
new file mode 100644
index 0000000..64bae12
--- /dev/null
+++ b/tests/PowerPauseTests.swift
@@ -0,0 +1,160 @@
+import AppKit
+import Combine
+
+/// Compile the real view model and power policy with inert hardware adapters.
+/// No installed preferences, power settings, screen locking, or UI are changed.
+@MainActor
+final class AutomaticLidManager {
+ var didFail: (() -> Void)?
+ var errorMessage: String?
+ var isEngaged = false
+ var isLidClosed = false
+ var starts = 0
+ var keptPrivacy = false
+ func start() async {
+ self.isEngaged = true
+ self.starts += 1
+ }
+
+ func stop(preservingLidPrivacy: Bool = false) {
+ self.isEngaged = false
+ self.keptPrivacy = preservingLidPrivacy
+ }
+}
+
+@MainActor
+final class SleepPreventionManager {
+ static let shared = SleepPreventionManager()
+ var held = false
+ func preventSleep() {
+ self.held = true
+ }
+
+ func allowSleep() {
+ self.held = false
+ }
+}
+
+@MainActor
+final class ActivitySimulator {
+ static let shared = ActivitySimulator()
+ var running = false
+ func requestPermission() {}
+ func startMonitoring() {
+ self.running = true
+ }
+
+ func stopMonitoring() {
+ self.running = false
+ }
+}
+
+extension PowerSnapshot {
+ static func current() -> Self {
+ Self(onExternalPower: true, batteryFraction: 1, isTooHot: false)
+ }
+}
+
+@MainActor
+final class PowerSourceMonitor {
+ init(changed _: @escaping () -> Void) {}
+ func start() {}
+ func stop() {}
+}
+
+@main
+struct PowerPauseTests {
+ @MainActor
+ static func settle() async throws {
+ try await Task.sleep(for: .milliseconds(30))
+ }
+
+ @MainActor
+ static func main() async throws {
+ UserDefaults.standard.setVolatileDomain([
+ PreferenceKeys.activateAtLaunch: false,
+ PreferenceKeys.suppressLaunchMessage: true,
+ PreferenceKeys.keepAppsActive: true,
+ PreferenceKeys.automaticLidControl: true,
+ PreferenceKeys.deactivateOnManualSleep: true,
+ ], forName: UserDefaults.argumentDomain)
+ var power = PowerSnapshot(onExternalPower: false, batteryFraction: 0.11, isTooHot: false)
+ let model = CaffeineViewModel(readPower: { power })
+ model.activate(withTimeout: 60)
+ try await self.settle()
+ precondition(model.isActive && !model.isPaused && model.automaticLid.isEngaged)
+ precondition(SleepPreventionManager.shared.held && ActivitySimulator.shared.running)
+ let remaining = model.timeRemaining
+
+ power.batteryFraction = 0.10
+ model.refreshPowerState()
+ precondition(model.isActive && model.pauseReason == .lowBattery && !model.showPreferences)
+ precondition(!SleepPreventionManager.shared.held && !ActivitySimulator.shared.running)
+ precondition(!model.automaticLid.isEngaged && model.automaticLid.keptPrivacy)
+ precondition(model.timeRemaining == remaining)
+ precondition(model.formattedTimeRemaining() == PowerPauseReason.lowBattery.message)
+ model.updateActivitySimulation(enabled: true)
+ precondition(!ActivitySimulator.shared.running)
+
+ power.batteryFraction = 0.12
+ model.refreshPowerState()
+ precondition(model.pauseReason == .lowBattery, "Must wait for the charger, not a fluctuating percentage")
+ power.onExternalPower = nil
+ model.refreshPowerState()
+ precondition(model.pauseReason == .lowBattery, "Unknown power source must not resume")
+ NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.willSleepNotification, object: nil)
+ try await self.settle()
+ precondition(model.isActive && model.isPaused, "Pause must survive ordinary system sleep")
+
+ power.onExternalPower = true
+ power.batteryFraction = 0.04
+ NSWorkspace.shared.notificationCenter.post(name: NSWorkspace.didWakeNotification, object: nil)
+ try await self.settle()
+ precondition(model.isActive && !model.isPaused && model.automaticLid.isEngaged)
+ precondition(model.timeRemaining == remaining, "Resume must not reset the duration")
+ precondition(!model.showPreferences)
+
+ power.onExternalPower = false
+ power.isTooHot = true
+ model.refreshPowerState()
+ precondition(model.pauseReason == .overheating && !model.showPreferences)
+ power.isTooHot = false
+ model.refreshPowerState()
+ precondition(model.pauseReason == .lowBattery, "Cooling must retain the wait for AC")
+ power.isTooHot = true
+ power.onExternalPower = true
+ model.refreshPowerState()
+ precondition(model.pauseReason == .overheating, "AC must not bypass thermal protection")
+ power.isTooHot = false
+ model.refreshPowerState()
+ try await self.settle()
+ precondition(!model.isPaused && model.automaticLid.isEngaged)
+
+ power.onExternalPower = false
+ model.refreshPowerState()
+ model.deactivate()
+ power.onExternalPower = true
+ model.refreshPowerState()
+ try await self.settle()
+ precondition(!model.isActive && !model.automaticLid.isEngaged)
+ precondition(!SleepPreventionManager.shared.held, "Manual off must cancel automatic resume")
+
+ power.onExternalPower = false
+ model.activate(withTimeout: 0.02)
+ precondition(model.isPaused && !model.automaticLid.isEngaged, "Low-battery activation must begin paused")
+ try await self.settle()
+ power.onExternalPower = true
+ model.refreshPowerState()
+ precondition(!model.isActive && !model.automaticLid.isEngaged, "Expired deadline must never resume")
+ precondition(!model.showPreferences)
+
+ // A queued activation must not win a race with the low-battery pause.
+ model.activate(withTimeout: 0)
+ power.onExternalPower = false
+ model.refreshPowerState()
+ try await self.settle()
+ precondition(model.isPaused && !model.automaticLid.isEngaged)
+ model.deactivate()
+ print("Power pause, charger resume, exact reasons, privacy retention, timer and cancellation tests passed")
+ }
+}