Skip to content

Add Launch at Login + lid-close support, Traditional Chinese, automated tests#1

Merged
bubbleee030 merged 8 commits into
masterfrom
feature/launch-at-login-and-lid-close
May 19, 2026
Merged

Add Launch at Login + lid-close support, Traditional Chinese, automated tests#1
bubbleee030 merged 8 commits into
masterfrom
feature/launch-at-login-and-lid-close

Conversation

@bubbleee030
Copy link
Copy Markdown
Owner

Summary

Adds two long-requested toggles, a 14th localization, and the testing infrastructure they ride
on.

  • Launch at Login — Preferences toggle that registers the app as a macOS login item via
    SMAppService.mainApp. Sandbox-safe, no helper bundle. Source of truth is SMAppService.status,
    so manual changes in System Settings → Login Items flow back into the UI on next appearance.
  • Allow Mac to run with lid closed — Preferences toggle that adds a
    kIOPMAssertionTypePreventSystemSleep assertion 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.
  • Sleep prevention upgraded — the manager now also holds
    kIOPMAssertPreventUserIdleSystemSleep so the whole system stays awake during idle, not just the
    display. Assertion timeout bumped 8 s → 30 s so the 10 s refresh always overlaps (the old values
    left a 2 s gap every cycle).
  • Traditional Chinese (zh-Hant) — full new locale. The three new strings are also added to
    all 13 existing locales (best-effort native translations for de/es/fr/it/nl/pt/pt-BR/ru/uk —
    native-speaker review welcome).
  • Traditional Chinese README (README.zh-Hant.md) with a step-by-step tutorial for the two
    new toggles.
  • README rewrite — removes upstream caffeine-app.net / intelliscapesolutions.com download
    and 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.
  • Automated tests — root Package.swift adds a CaffeineCore library + CaffeineCoreTests
    target. Zero changes to .xcodeproj (per CLAUDE.md). 16 unit tests cover the new wiring via
    protocol-injected backends.
  • Integration scriptscripts/integration-test.sh builds the .app and verifies pmset -g assertions reflects the expected IOPMAssertion types in both lid-closed and lid-open modes,
    using a #if DEBUG env-var hook (CA_TEST_AUTOACTIVATE).

Architecture notes

  • LaunchItemBackend / PowerAssertionBackend are thin protocol abstractions over
    SMAppService and IOPMAssertionCreateWithDescription so the managers are unit-testable.
  • LaunchAtLoginManager and SleepPreventionManager accept an injected backend; production code
    keeps the singleton entry points (SMAppServiceBackend.shared,
    IOKitPowerAssertionBackend.shared).
  • SleepPreventionManager is @MainActor to keep timer mutation main-confined under Swift 6
    concurrency.

Test plan

  • swift test — 16/16 passing (smoke, LaunchAtLogin × 5, SleepPrevention × 7, Localization ×
  • xcodebuild -scheme Caffeine -destination 'platform=macOS' buildBUILD SUCCEEDED
  • scripts/integration-test.sh — confirms PreventUserIdleDisplaySleep +
    PreventUserIdleSystemSleep always; PreventSystemSleep only when lid-close is on
  • Built .app launched manually: Preferences shows both new toggles; Launch at Login
    round-trips with System Settings → Login Items; pmset -g assertions | grep -i caffeine matches
    the toggle state
  • swiftformat . — clean (config in .swiftformat picked up)
  • git diff --stat master..HEAD — no .xcodeproj / .xcworkspace / .pbxproj files
    touched
  • zh-Hant locale picked up by the build — Caffeine.app/Contents/Resources/zh-Hant.lproj/
    present

Files

Area Files
New SPM library Package.swift
New backends src/Caffeine/Classes/Models/LaunchItemBackend.swift,
…/PowerAssertionBackend.swift
New managers …/LaunchAtLoginManager.swift, rewritten …/SleepPreventionManager.swift
View model …/ViewModels/CaffeineViewModel.swift — new allowLidClose preference key,
lid-close wiring, #if DEBUG test hook
UI …/Views/PreferencesView.swift — two new toggles and caption
Tests `Tests/CaffeineCoreTests/{PackageSmoke,LaunchAtLoginManager,SleepPreventionManager,Loca
lization}Tests.swift`
Integration scripts/integration-test.sh
Localization new src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings; 3 new keys
appended to every other *.lproj/Localizable.strings
Docs rewritten README.md; new README.zh-Hant.md; CHANGELOG.md

Known caveats

  • Lid-close on battery is governed by Apple's clamshell policy; PreventSystemSleep is
    documented to work reliably only on AC. Flagged in the UI caption and README.
  • Best-effort translations for de/es/fr/it/nl/pt/pt-BR/ru/uk on the three new strings — open
    follow-up PRs for native-speaker refinement.
  • No new Xcode test target (would require .xcodeproj edits). Tests run via swift test; the
    existing xcodebuild test scheme action remains unused.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/SleepPreventionManager with injectable backends, lid-close support, system-idle sleep prevention, and 30 s assertion timeout
  • New PreferencesView toggles, CaffeineViewModel.setAllowLidClose, and CA_TEST_AUTOACTIVATE DEBUG hook; new allowLidClose defaults key
  • New zh-Hant locale + 3 new strings in all 13 existing locales; new SPM library CaffeineCore with 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.

Comment on lines +87 to 108
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()
}

Comment on lines +35 to +49
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
}
Comment on lines +46 to +56
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 30 changed files in this pull request and generated 5 comments.

Comment on lines +90 to +97
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))
Comment on lines +89 to +90
private func refreshAssertions() {
guard self.isUserSessionActive else { return }
Comment on lines +36 to +45
#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
Comment on lines +105 to +110
// 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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 31 changed files in this pull request and generated no new comments.

@bubbleee030 bubbleee030 merged commit b6f504e into master May 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants