Add Launch at Login + lid-close support, Traditional Chinese, automated tests#1
Conversation
Introduces a root Package.swift that compiles a subset of model code into a CaffeineCore library so swift test can run automated unit tests without touching the Xcode project. Adds two backend protocols that abstract the system APIs that subsequent milestones will depend on: - LaunchItemBackend wraps SMAppService.mainApp for login-item registration. - PowerAssertionBackend wraps IOPMAssertionCreateWithDescription / IOPMAssertionRelease for sleep prevention. Includes a smoke test exercising the default implementations.
Adds a new "Launch at Login" toggle in Preferences that registers the app as a macOS login item via SMAppService.mainApp. The published state mirrors the underlying SMAppService status so manual toggles in System Settings flow back into the UI on next appearance. Tests cover register/unregister wiring, the no-op-when-already-enabled case, and recovery when the backend throws.
Adds an "Allow Mac to run with lid closed" preference. SleepPreventionManager
now holds up to three IOPMAssertions while active:
- PreventUserIdleDisplaySleep (display does not dim from inactivity)
- PreventUserIdleSystemSleep (system does not idle-sleep — previously absent)
- PreventSystemSleep (only when the new flag is on, lets the Mac
keep running with the lid closed on AC power)
The assertion timeout is bumped from 8 s to 30 s so the 10 s refresh always
overlaps; the previous values left a 2 s gap every cycle.
The manager accepts an injected PowerAssertionBackend so tests can verify
which assertion types are created. Toggling the flag while active applies
live without re-activating.
Integration smoke test in scripts/integration-test.sh launches the built
app with a debug env var, then verifies `pmset -g assertions` reflects the
expected types.
Adds zh-Hant.lproj/Localizable.strings (full translation) and appends the three new preference strings — "Launch at Login", "Allow Mac to run with lid closed", and the AC-power caveat — to every shipped locale (best-effort native translations for de/es/fr/it/nl/pt/pt-BR/ru/uk; native review welcome via follow-up PRs). LocalizationTests parses each .strings file and asserts the full key set is present, so future user-facing strings can't land without updating every locale.
The README now describes this fork (bubbleee030/Caffeine) instead of the upstream project's download page and support contact. Adds a tutorial for the two new toggles, including a `pmset -g assertions` verification step, a Languages section listing all 14 shipped locales, and a Building from source section. The original FAQ entries are kept and the alternatives question is updated to reflect that this fork now provides lid-close parity with Amphetamine. README.zh-Hant.md mirrors the English README with localized headings and Taiwan-style vocabulary so Traditional-Chinese readers get the same information without an English detour.
There was a problem hiding this comment.
Pull request overview
This PR adds two new user preferences to a Caffeine fork — "Launch at Login" (via SMAppService.mainApp) and "Allow Mac to run with lid closed" (via an additional PreventSystemSleep IOPMAssertion) — refactors SleepPreventionManager behind a PowerAssertionBackend protocol with broader assertion coverage and a 30 s timeout, adds a Traditional Chinese (zh-Hant) localization plus three new keys in every existing locale, and introduces a root Package.swift with swift test unit tests and a pmset-based integration script. The README is rewritten to point at the fork and document the new toggles.
Changes:
- New
LaunchAtLoginManager/SleepPreventionManagerwith injectable backends, lid-close support, system-idle sleep prevention, and 30 s assertion timeout - New
PreferencesViewtoggles,CaffeineViewModel.setAllowLidClose, andCA_TEST_AUTOACTIVATEDEBUG hook; newallowLidClosedefaults key - New
zh-Hantlocale + 3 new strings in all 13 existing locales; new SPM libraryCaffeineCorewith 16 unit tests; integration shell script; README rewrite +README.zh-Hant.md
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
Package.swift |
New SPM target exposing model files for swift test. |
.gitignore |
Ignore SPM build artifacts. |
CHANGELOG.md |
Documents new toggles, locale, tests, and assertion changes. |
README.md, README.zh-Hant.md |
Rewritten/new docs pointing at the fork and explaining new toggles. |
src/Caffeine/Classes/Models/LaunchAtLoginManager.swift |
New @Observable manager wrapping LaunchItemBackend. |
src/Caffeine/Classes/Models/LaunchItemBackend.swift |
Protocol + SMAppService.mainApp backend for login-item registration. |
src/Caffeine/Classes/Models/PowerAssertionBackend.swift |
Protocol + IOPMAssertionCreateWithDescription backend. |
src/Caffeine/Classes/Models/SleepPreventionManager.swift |
Rewritten to @MainActor, multi-assertion, 30 s timeout; deinit removed. |
src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift |
New allowLidClose key, setAllowLidClose, DEBUG auto-activate hook. |
src/Caffeine/Classes/Views/PreferencesView.swift |
Two new toggles + caption; refreshes loginManager on appear. |
src/Caffeine/Resources/*.lproj/Localizable.strings |
3 new keys added in each existing locale; new zh-Hant.lproj. |
Tests/CaffeineCoreTests/*.swift |
New unit tests for managers and localization coverage. |
scripts/integration-test.sh |
Builds .app and verifies pmset -g assertions for both modes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private func refreshAssertions() { | ||
| guard self.isUserSessionActive else { return } | ||
| self.releaseAll() | ||
| let reason = String(localized: "Caffeine prevents sleep") | ||
| self.idleDisplayAssertionID = self.backend.create( | ||
| type: kIOPMAssertPreventUserIdleDisplaySleep as String, | ||
| reason: reason, | ||
| timeout: 30 | ||
| ) | ||
|
|
||
| if result == kIOReturnSuccess { | ||
| self.sleepAssertionID = assertionID | ||
| self.idleSystemAssertionID = self.backend.create( | ||
| type: kIOPMAssertPreventUserIdleSystemSleep as String, | ||
| reason: reason, | ||
| timeout: 30 | ||
| ) | ||
| if self.allowLidClose { | ||
| self.preventSystemAssertionID = self.backend.create( | ||
| type: kIOPMAssertionTypePreventSystemSleep as String, | ||
| reason: reason, | ||
| timeout: 30 | ||
| ) | ||
| } | ||
| } |
| self.backend = backend | ||
| self.setupWorkspaceNotifications() | ||
| } | ||
|
|
| public func setEnabled(_ enabled: Bool) -> Bool { | ||
| let succeeded: Bool | ||
| do { | ||
| if enabled, !self.backend.isEnabled { | ||
| try self.backend.register() | ||
| } else if !enabled, self.backend.isEnabled { | ||
| try self.backend.unregister() | ||
| } | ||
| succeeded = true | ||
| } catch { | ||
| succeeded = false | ||
| } | ||
| self.refresh() | ||
| return succeeded | ||
| } |
| CA_TEST_AUTOACTIVATE="$mode" "$BINARY" & | ||
| APP_PID=$! | ||
| sleep 3 | ||
|
|
||
| local out | ||
| out=$(pmset -g assertions | grep -E "\(Caffeine\)" || true) | ||
| if [[ -z "$out" ]]; then | ||
| echo "FAIL: no Caffeine-owned assertions in pmset output" | ||
| pmset -g assertions | tail -50 | ||
| return 1 | ||
| fi |
#1 SleepPreventionManager.refreshAssertions now creates the three new assertions before releasing the old IDs, so the kernel always sees at least one of each type during a refresh (swap-then-release, not release-then-create). The doc comment claimed overlap but the code left a microsecond gap. New testRefreshCreatesNewAssertionsBeforeReleasingOld pins the ordering via an operationLog on the test fake. domzilla#2 NSWorkspace session observers now use the Combine publisher API with AnyCancellable, so they detach automatically when the manager deallocates. The previous selector-based addObserver retained the target forever — fine for the production singleton, but every test that constructed a SleepPreventionManager(backend:) leaked two observers. testManagerDeallocatesWhenOutOfScope proves the leak is fixed via a weak ref. domzilla#3 LaunchAtLoginManager.setEnabled now logs the underlying error via os.Logger (subsystem net.domzilla.caffeine, visible in Console.app) and surfaces it on a public `lastError` property so the UI layer can present it to the user. refresh() still snaps the published isEnabled back to the backend truth, so a failed register visually pops the Toggle back to off. Two new tests cover lastError set on throw and cleared on next success. domzilla#4 scripts/integration-test.sh replaces the fixed 3 s sleep with a poll_for_caffeine_assertions helper that retries pmset every second until Caffeine-owned assertions appear (default 30 s timeout, overridable via ASSERTION_POLL_TIMEOUT). Avoids flakiness on slow runners where SwiftUI initialization takes longer than 3 s.
| Toggle("Allow Mac to run with lid closed", isOn: Binding( | ||
| get: { self.allowLidClose }, | ||
| set: { newValue in | ||
| self.allowLidClose = newValue | ||
| self.viewModel.setAllowLidClose(newValue) | ||
| } | ||
| )) | ||
| .font(.system(size: 13)) |
| private func refreshAssertions() { | ||
| guard self.isUserSessionActive else { return } |
| #if DEBUG | ||
| // Test hook: integration script sets CA_TEST_AUTOACTIVATE=lid-closed | ||
| // (or any other value) to force activation on launch with a known | ||
| // lid-close flag. Compiled out in Release. | ||
| if let mode = ProcessInfo.processInfo.environment["CA_TEST_AUTOACTIVATE"] { | ||
| UserDefaults.standard.set(mode == "lid-closed", forKey: PreferenceKeys.allowLidClose) | ||
| self.activate() | ||
| return | ||
| } | ||
| #endif |
| // The two scripts use different characters; a representative key must | ||
| // not be identical across them. | ||
| XCTAssertNotEqual( | ||
| hant["Quit"], | ||
| hans["Quit"], | ||
| "zh-Hant should use Traditional characters, not the Simplified value" |
| "Welcome to Caffeine" = "歡迎使用 Caffeine"; | ||
|
|
||
| /* About credits */ | ||
| "© 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\n原始碼:\nhttps://github.caffeine-app.net"; |
domzilla#5 PreferencesView no longer writes CAAllowLidClose to UserDefaults twice. CaffeineViewModel.setAllowLidClose now only forwards the new flag to SleepPreventionManager; persistence is owned by PreferencesView's @AppStorage binding. domzilla#6 SleepPreventionManager now releases its assertion IDs immediately when the user session resigns active, and re-engages immediately on become- active rather than waiting up to 10 s for the next timer tick. The old early-return in refreshAssertions left the manager's stored IDs in a stale state after the kernel timed them out at 30 s. Three new tests cover resign-releases, become-reengages, and become-while-inactive does nothing. domzilla#7 CA_TEST_AUTOACTIVATE no longer writes to standard UserDefaults. The activate(...) function takes a new allowLidCloseOverride parameter so the test hook can drive a known state without mutating persistent preferences. The intentional early-return is documented in comments. domzilla#8 LocalizationTests no longer relies on a single representative key ("Quit") to prove zh-Hant differs from zh-Hans. Asserts that at least half of all 31 user-facing values are non-identical across the two locales. domzilla#9 About-dialog credits now point at github.com/bubbleee030/Caffeine instead of github.caffeine-app.net, in line with the README rewrite. Added a 2026 @bubbleee030 line to the copyright credits. Updated the source string in MenuBarController and the localized value in all 14 .lproj/Localizable.strings files; LocalizationTests reflects the new key. No remaining caffeine-app.net references in the repo.
Summary
Adds two long-requested toggles, a 14th localization, and the testing infrastructure they ride
on.
SMAppService.mainApp. Sandbox-safe, no helper bundle. Source of truth isSMAppService.status,so manual changes in System Settings → Login Items flow back into the UI on next appearance.
kIOPMAssertionTypePreventSystemSleepassertion alongside the existing display/idle assertions,so a portable Mac on AC power keeps running with the lid closed (Amphetamine-parity). Includes a
caption under the toggle noting that on battery, macOS may still sleep — that's enforced by the
kernel.
kIOPMAssertPreventUserIdleSystemSleepso the whole system stays awake during idle, not just thedisplay. Assertion timeout bumped 8 s → 30 s so the 10 s refresh always overlaps (the old values
left a 2 s gap every cycle).
all 13 existing locales (best-effort native translations for de/es/fr/it/nl/pt/pt-BR/ru/uk —
native-speaker review welcome).
README.zh-Hant.md) with a step-by-step tutorial for the twonew toggles.
caffeine-app.net/intelliscapesolutions.comdownloadand support links, points at this fork's Releases and Issues. FAQ retained; "vs. Amphetamine"
answer updated to reflect lid-close parity. Adds Quickstart, Features, Tutorial, Languages,
Building-from-source sections.
Package.swiftadds aCaffeineCorelibrary +CaffeineCoreTeststarget. Zero changes to
.xcodeproj(perCLAUDE.md). 16 unit tests cover the new wiring viaprotocol-injected backends.
scripts/integration-test.shbuilds the.appand verifiespmset -g assertionsreflects the expectedIOPMAssertiontypes in both lid-closed and lid-open modes,using a
#if DEBUGenv-var hook (CA_TEST_AUTOACTIVATE).Architecture notes
LaunchItemBackend/PowerAssertionBackendare thin protocol abstractions overSMAppServiceandIOPMAssertionCreateWithDescriptionso the managers are unit-testable.LaunchAtLoginManagerandSleepPreventionManageraccept an injected backend; production codekeeps the singleton entry points (
SMAppServiceBackend.shared,IOKitPowerAssertionBackend.shared).SleepPreventionManageris@MainActorto keep timer mutation main-confined under Swift 6concurrency.
Test plan
swift test— 16/16 passing (smoke, LaunchAtLogin × 5, SleepPrevention × 7, Localization ×xcodebuild -scheme Caffeine -destination 'platform=macOS' build—BUILD SUCCEEDEDscripts/integration-test.sh— confirmsPreventUserIdleDisplaySleep+PreventUserIdleSystemSleepalways;PreventSystemSleeponly when lid-close is on.applaunched manually: Preferences shows both new toggles; Launch at Loginround-trips with System Settings → Login Items;
pmset -g assertions | grep -i caffeinematchesthe toggle state
swiftformat .— clean (config in.swiftformatpicked up)git diff --stat master..HEAD— no.xcodeproj/.xcworkspace/.pbxprojfilestouched
Caffeine.app/Contents/Resources/zh-Hant.lproj/present
Files
Package.swiftsrc/Caffeine/Classes/Models/LaunchItemBackend.swift,…/PowerAssertionBackend.swift…/LaunchAtLoginManager.swift, rewritten…/SleepPreventionManager.swift…/ViewModels/CaffeineViewModel.swift— newallowLidClosepreference key,#if DEBUGtest hook…/Views/PreferencesView.swift— two new toggles and captionscripts/integration-test.shsrc/Caffeine/Resources/zh-Hant.lproj/Localizable.strings; 3 new keys*.lproj/Localizable.stringsREADME.md; newREADME.zh-Hant.md;CHANGELOG.mdKnown caveats
PreventSystemSleepisdocumented to work reliably only on AC. Flagged in the UI caption and README.
follow-up PRs for native-speaker refinement.
.xcodeprojedits). Tests run viaswift test; theexisting
xcodebuild testscheme action remains unused.