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..04b64ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. 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 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. +- Disabled upstream automatic updates for the separately identified direct-distribution fork. - Improved Ukrainian translation. ### 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. + +- 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 e48715d..be413d9 100755 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ +# Caffeine Lid — إضاءة مطفأة عند الإغلاق، وقفل عند الفتح + +Fork of [domzilla/Caffeine](https://github.com/domzilla/Caffeine) with automatic lid behavior tied to the normal Caffeine toggle. + +**العربية:** فعّل Caffeine من أيقونة الفنجان. عند إغلاق الغطاء ينخفض سطوع الشاشة وإضاءة الكيبورد إلى صفر مع بقاء الجهاز يعمل دون طلب قفله. عند فتح الغطاء يُقفل حساب Mac تلقائيًا، ثم تعود الإضاءة السابقة. يبقى Caffeine مفعّلًا بعد فتح القفل ويكرر السلوك تلقائيًا. صلاحية المسؤول مطلوبة عند تثبيت المساعد أول مرة فقط. يلزم اختبار السلوك على الجهاز فعليًا. + +**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`. See [behavior, recovery and validation](docs/PROTECTED-LID.md). + +--- + +## Upstream README + Icon # Caffeine diff --git a/docs/PROTECTED-LID.md b/docs/PROTECTED-LID.md new file mode 100644 index 0000000..0a7fbc2 --- /dev/null +++ b/docs/PROTECTED-LID.md @@ -0,0 +1,191 @@ +# Automatic lid control + +This direct-distribution fork couples lid behavior to the normal Caffeine toggle. +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 + +| 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 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, +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 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. 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. +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. +- 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 +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/` + +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 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 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 +that change the same global setting mid-session, as macOS exposes no per-app ownership. + +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. + +After quitting Caffeine, remove the persistent helper with: + +```sh +sudo /bin/sh scripts/uninstall-lid-helper.sh +``` + +Emergency power recovery, if launchd/powerd is unavailable: + +```sh +sudo pmset -a disablesleep 0 +``` + +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. + +## Validation + +```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 +/tmp/caffeine-lid-tests +bash scripts/build-lid.sh +swiftformat . +``` + +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. 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 +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. + +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) +- [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/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..69a229d 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() } } @@ -58,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/AutomaticLidManager.swift b/src/Caffeine/Classes/Models/AutomaticLidManager.swift new file mode 100644 index 0000000..d632492 --- /dev/null +++ b/src/Caffeine/Classes/Models/AutomaticLidManager.swift @@ -0,0 +1,310 @@ +import AppKit +import Combine +import IOKit +import IOKit.pwr_mgt + +/// 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 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 didFail: (() -> Void)? + + private var sessionID: UUID? + 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 + 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) + + var isEngaged: Bool { + self.isPreparing || self.isRunning + } + + func start() async { + guard !self.isEngaged else { return } + self.errorMessage = nil + guard + 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: "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. + if self.lidMonitor == nil, !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.")) + return + } + self.lidMonitor?.stop() + 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 + } + try await Self.authorize(Self.installCommand(script: String(contentsOf: scriptURL, encoding: .utf8))) + } + guard self.sessionID == id else { return } + try await self.helperSession.start(id: id) { self.helperIsInstalled() } + guard self.sessionID == id else { return } + self.isPreparing = false + self.isRunning = true + self.monitor = Task { [weak self] in + var heartbeat = Date.distantPast + while !Task.isCancelled { + do { try await Task.sleep(for: .milliseconds(250)) } catch { return } + guard let self, self.sessionID == id else { return } + guard let closed = LidMonitor.readClosed() else { self.fail() + 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; + // 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() + } + do { try self.helperSession.checkHealth() } catch { self.fail() + return + } + heartbeat = Date() + } + } + } + } catch { + guard self.sessionID == id else { return } + self.fail() + } + } + + func stop(preservingLidPrivacy: Bool = false) { + let keepPrivacy = preservingLidPrivacy && (self.cycle.closed || self.cycle.lockPending) + self.sessionID = nil + self.monitor?.cancel() + self.monitor = nil + if !keepPrivacy { + self.lidMonitor?.stop() + self.lidMonitor = nil + } + self.helperSession.stop() + self.isPreparing = false + 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 !keepPrivacy, !self.cycle.lockPending || self.cycle.closed { + self.lockTask?.cancel() + self.lockRequestID = nil + 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.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: + self.privacyShield.show() + self.darkenLights() + self.lockFunction?() + self.lockTask?.cancel() + let requestID = UUID() + self.lockRequestID = requestID + self.lockTask = Task { [weak self] in + 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() + if !self.isEngaged { + self.lidMonitor?.stop() + self.lidMonitor = nil + } + } + self.lockRequestID = nil + return + } + if attempt > 0, attempt % 20 == 0, Self.isScreenLocked() != true { + self.lockFunction?() + } + 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 } + } + } + } + } + + private func darkenLights() { + self.display.darken() + self.keyboard.darken() + } + + 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 + 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 = message ?? + String( + 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 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 == "2" + } + + 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 enum LidError: Error { case unavailable } +} 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/LidHelperSession.swift b/src/Caffeine/Classes/Models/LidHelperSession.swift new file mode 100644 index 0000000..077e591 --- /dev/null +++ b/src/Caffeine/Classes/Models/LidHelperSession.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Serializes each client handshake with the helper's asynchronous cleanup. +@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") + } + + 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 + 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 + } + } + + func checkHealth() throws { + try self.heartbeat.checkHealth() + guard let id = self.sessionID, self.status == "active:\(id.uuidString)" else { throw SessionError.unavailable } + } + + 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) + { + 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() } + 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. + "\(active ? "locked" : "pending"):\(ProcessInfo.processInfo.processIdentifier):\(id.uuidString)" + } + + 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/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/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/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/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/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 407c4d1..cfc3271 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -17,21 +17,36 @@ class CaffeineViewModel: ObservableObject { @Published var isActive = false @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 + UserDefaults.standard.register(defaults: [PreferenceKeys.automaticLidControl: true]) self.setupObservers() + self.automaticLid.didFail = { [weak self] in + self?.deactivate() + self?.showPreferences = true + } // Check if we should activate at launch if UserDefaults.standard.bool(forKey: PreferenceKeys.activateAtLaunch) { @@ -108,10 +123,50 @@ class CaffeineViewModel: ObservableObject { } self.isActive = true - SleepPreventionManager.shared.preventSleep() + 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() + } + } + } + } - if UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) { - ActivitySimulator.shared.startMonitoring() + 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, !self.isPaused, + UserDefaults.standard.bool(forKey: PreferenceKeys.automaticLidControl) else { return } + await self.automaticLid.start() + } } } @@ -120,19 +175,34 @@ 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() } + func updateAutomaticLidControl(enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: PreferenceKeys.automaticLidControl) + 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 { + 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 { + if enabled, self.isActive, !self.isPaused, !self.automaticLid.isLidClosed { ActivitySimulator.shared.startMonitoring() } else { ActivitySimulator.shared.stopMonitoring() @@ -147,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) @@ -175,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() } } @@ -187,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) @@ -211,5 +284,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 new file mode 100644 index 0000000..de224e1 --- /dev/null +++ b/src/Caffeine/Classes/Views/AutomaticLidControls.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct AutomaticLidControls: View { + @ObservedObject var manager: AutomaticLidManager + @Binding var isEnabled: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + 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 self.isEnabled, let error = self.manager.errorMessage { + Text(error).font(.system(size: 11)).foregroundColor(.red) + .padding(.leading, 20) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift index 8747766..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,6 +105,18 @@ class MenuBarController: NSObject { menu.addItem(NSMenuItem.separator()) } + if !self.viewModel.isPaused, 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( title: String(localized: "Activate for"), @@ -155,17 +174,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 +215,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..0dd5bbb 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,6 +98,24 @@ struct PreferencesView: View { .padding(.leading, 20) } + 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) + } + )) + + 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/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..8acc9b3 --- /dev/null +++ b/src/Caffeine/Resources/ar.lproj/Localizable.strings @@ -0,0 +1,51 @@ +"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 يمنع السكون"; +"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." = "التحكم بإضاءة الكيبورد غير متاح على هذا الجهاز؛ سيستمر التحكم التلقائي بالشاشة والغطاء."; +"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" = "الغطاء: إطفاء الإضاءة عند الإغلاق، والقفل عند الفتح"; + +/* 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. تبقى الشاشة مغطاة لحماية الخصوصية."; + +/* 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 66e5e4c..b23b657 100644 --- a/src/Caffeine/Resources/en.lproj/Localizable.strings +++ b/src/Caffeine/Resources/en.lproj/Localizable.strings @@ -43,3 +43,26 @@ /* System messages */ "Caffeine prevents sleep" = "Caffeine prevents sleep"; +/* 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."; +"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"; + +/* 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."; + +/* 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/src/Caffeine/Resources/protected-lid-watchdog.sh b/src/Caffeine/Resources/protected-lid-watchdog.sh new file mode 100644 index 0000000..ae3b16a --- /dev/null +++ b/src/Caffeine/Resources/protected-lid-watchdog.sh @@ -0,0 +1,117 @@ +#!/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='' +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 + rm -f "$state/owned" + fi + changed=0 +} +cleanup() { + trap '' HUP INT TERM + restore + set_status offline +} +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 '2' > "$state/version" +set_status idle +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 + # 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='' + [ "$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 + set_status idle + 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 + set_status "ready:$nonce" + else + set_status "conflict:$nonce" + 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 + set_status "conflict:$nonce" + /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 + set_status "active:$nonce" + else + restore + phase=idle + set_status "failed:$nonce" + fi + fi + /bin/sleep 1 +done 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") + } +} diff --git a/tests/LidHelperSessionTests.swift b/tests/LidHelperSessionTests.swift new file mode 100644 index 0000000..017559a --- /dev/null +++ b/tests/LidHelperSessionTests.swift @@ -0,0 +1,52 @@ +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 { + 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.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)) + 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.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() + print("Rapid off/on and cancelled-start regression tests passed") + } +} 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") + } +} 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") + } +} diff --git a/tests/test_lid_watchdog.py b/tests/test_lid_watchdog.py new file mode 100644 index 0000000..9459823 --- /dev/null +++ b/tests/test_lid_watchdog.py @@ -0,0 +1,271 @@ +"""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_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 / "src/Caffeine/Classes/Models/LidLeaseHeartbeat.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), "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}") + 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_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) + 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()