From f9a0d8748d22e4ab3feb7de1cd92287ea9c5e9b1 Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 02:58:44 +0800 Subject: [PATCH 1/8] Add Swift Package test scaffold and IO backend protocols 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. --- .gitignore | 6 +++ Package.swift | 35 ++++++++++++++ .../CaffeineCoreTests/PackageSmokeTests.swift | 18 ++++++++ .../Classes/Models/LaunchItemBackend.swift | 37 +++++++++++++++ .../Models/PowerAssertionBackend.swift | 46 +++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 Package.swift create mode 100644 Tests/CaffeineCoreTests/PackageSmokeTests.swift create mode 100644 src/Caffeine/Classes/Models/LaunchItemBackend.swift create mode 100644 src/Caffeine/Classes/Models/PowerAssertionBackend.swift diff --git a/.gitignore b/.gitignore index 23137f2..4e44ce6 100755 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,12 @@ iOSInjectionProject/ .DS_Store *.swp + +# Swift Package Manager (root Package.swift is for tests; build artifacts are local) +.build/ +.swiftpm/ +Package.resolved + /sparkle/framework/bin /sparkle/framework/CHANGELOG /sparkle/framework/INSTALL diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ca24abc --- /dev/null +++ b/Package.swift @@ -0,0 +1,35 @@ +// swift-tools-version: 6.0 +// +// Side Swift Package for automated unit tests of Caffeine's testable model code. +// +// The Xcode project (`src/Caffeine.xcodeproj`) is the source of truth for the +// shipping app. This package compiles a subset of the model files into a +// separate `CaffeineCore` library so that `swift test` can run unit tests +// against them without touching the Xcode project. Xcode and SPM compile the +// same .swift files independently — there is no conflict because each build +// system produces its own artifacts. + +import PackageDescription + +let package = Package( + name: "CaffeineCore", + platforms: [.macOS(.v14)], + products: [ + .library(name: "CaffeineCore", targets: ["CaffeineCore"]), + ], + targets: [ + .target( + name: "CaffeineCore", + path: "src/Caffeine/Classes/Models", + sources: [ + "LaunchItemBackend.swift", + "PowerAssertionBackend.swift", + ] + ), + .testTarget( + name: "CaffeineCoreTests", + dependencies: ["CaffeineCore"], + path: "Tests/CaffeineCoreTests" + ), + ] +) diff --git a/Tests/CaffeineCoreTests/PackageSmokeTests.swift b/Tests/CaffeineCoreTests/PackageSmokeTests.swift new file mode 100644 index 0000000..10d302e --- /dev/null +++ b/Tests/CaffeineCoreTests/PackageSmokeTests.swift @@ -0,0 +1,18 @@ +// +// PackageSmokeTests.swift +// CaffeineCoreTests +// + +import XCTest +@testable import CaffeineCore + +final class PackageSmokeTests: XCTestCase { + @MainActor + func testLoginBackendSharedExists() { + XCTAssertNotNil(SMAppServiceBackend.shared) + } + + func testPowerBackendSharedExists() { + XCTAssertNotNil(IOKitPowerAssertionBackend.shared) + } +} diff --git a/src/Caffeine/Classes/Models/LaunchItemBackend.swift b/src/Caffeine/Classes/Models/LaunchItemBackend.swift new file mode 100644 index 0000000..31c2511 --- /dev/null +++ b/src/Caffeine/Classes/Models/LaunchItemBackend.swift @@ -0,0 +1,37 @@ +// +// LaunchItemBackend.swift +// Caffeine +// + +import Foundation +import ServiceManagement + +/// Abstraction over the system login-item registration API. Production uses +/// ``SMAppServiceBackend``; tests inject a fake. +@MainActor +public protocol LaunchItemBackend: AnyObject { + var isEnabled: Bool { get } + func register() throws + func unregister() throws +} + +/// Default backend that registers the running app as a macOS login item via +/// `SMAppService.mainApp`. Sandbox-safe; requires macOS 13+. +@MainActor +public final class SMAppServiceBackend: LaunchItemBackend { + public static let shared = SMAppServiceBackend() + + public init() {} + + public var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + + public func register() throws { + try SMAppService.mainApp.register() + } + + public func unregister() throws { + try SMAppService.mainApp.unregister() + } +} diff --git a/src/Caffeine/Classes/Models/PowerAssertionBackend.swift b/src/Caffeine/Classes/Models/PowerAssertionBackend.swift new file mode 100644 index 0000000..e63936d --- /dev/null +++ b/src/Caffeine/Classes/Models/PowerAssertionBackend.swift @@ -0,0 +1,46 @@ +// +// PowerAssertionBackend.swift +// Caffeine +// + +import Foundation +import IOKit.pwr_mgt + +/// Abstraction over the `IOPMAssertion` C API. Production uses +/// ``IOKitPowerAssertionBackend``; tests inject a recorder so they can assert +/// on the (type, reason) tuples that were requested. +public protocol PowerAssertionBackend: AnyObject { + /// Creates an assertion of `type` with `reason` and the given `timeout` + /// seconds. Returns the assertion ID on success, or `nil` if the kernel + /// rejected the request. + func create(type: String, reason: String, timeout: TimeInterval) -> UInt32? + /// Releases a previously-created assertion. No-op if `id` was never created. + func release(_ id: UInt32) +} + +/// Default backend backed by `IOPMAssertionCreateWithDescription` / +/// `IOPMAssertionRelease`. Stateless — safe to share across actors. +public final class IOKitPowerAssertionBackend: PowerAssertionBackend, Sendable { + public static let shared = IOKitPowerAssertionBackend() + + public init() {} + + public func create(type: String, reason: String, timeout: TimeInterval) -> UInt32? { + var id: IOPMAssertionID = 0 + let result = IOPMAssertionCreateWithDescription( + type as CFString, + reason as CFString, + nil, + nil, + nil, + timeout, + nil, + &id + ) + return result == kIOReturnSuccess ? id : nil + } + + public func release(_ id: UInt32) { + IOPMAssertionRelease(id) + } +} From 2ebef572e6174da4d52b390fa8ee6302e37608c3 Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 03:00:24 +0800 Subject: [PATCH 2/8] Add Launch at Login preference 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. --- Package.swift | 1 + .../LaunchAtLoginManagerTests.swift | 94 +++++++++++++++++++ .../Classes/Models/LaunchAtLoginManager.swift | 50 ++++++++++ .../Classes/Views/PreferencesView.swift | 8 ++ 4 files changed, 153 insertions(+) create mode 100644 Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift create mode 100644 src/Caffeine/Classes/Models/LaunchAtLoginManager.swift diff --git a/Package.swift b/Package.swift index ca24abc..c5ebb54 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( name: "CaffeineCore", path: "src/Caffeine/Classes/Models", sources: [ + "LaunchAtLoginManager.swift", "LaunchItemBackend.swift", "PowerAssertionBackend.swift", ] diff --git a/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift new file mode 100644 index 0000000..ffb9098 --- /dev/null +++ b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift @@ -0,0 +1,94 @@ +// +// LaunchAtLoginManagerTests.swift +// CaffeineCoreTests +// + +import XCTest +@testable import CaffeineCore + +@MainActor +final class LaunchAtLoginManagerTests: XCTestCase { + func testSetEnabledTrueRegistersAndPublishes() { + let backend = FakeLaunchItemBackend() + let manager = LaunchAtLoginManager(backend: backend) + + let ok = manager.setEnabled(true) + + XCTAssertTrue(ok) + XCTAssertEqual(backend.registerCalls, 1) + XCTAssertEqual(backend.unregisterCalls, 0) + XCTAssertTrue(manager.isEnabled) + } + + func testSetEnabledFalseUnregistersAndPublishes() { + let backend = FakeLaunchItemBackend() + backend.isEnabled = true + let manager = LaunchAtLoginManager(backend: backend) + + let ok = manager.setEnabled(false) + + XCTAssertTrue(ok) + XCTAssertEqual(backend.unregisterCalls, 1) + XCTAssertEqual(backend.registerCalls, 0) + XCTAssertFalse(manager.isEnabled) + } + + func testSetEnabledTrueWhenAlreadyEnabledIsNoop() { + let backend = FakeLaunchItemBackend() + backend.isEnabled = true + let manager = LaunchAtLoginManager(backend: backend) + + _ = manager.setEnabled(true) + + XCTAssertEqual(backend.registerCalls, 0) + XCTAssertTrue(manager.isEnabled) + } + + func testSetEnabledReturnsFalseAndReflectsBackendWhenRegisterThrows() { + let backend = FakeLaunchItemBackend() + backend.registerError = FakeError.boom + let manager = LaunchAtLoginManager(backend: backend) + + let ok = manager.setEnabled(true) + + XCTAssertFalse(ok) + XCTAssertEqual(backend.registerCalls, 1) + XCTAssertFalse(manager.isEnabled, "register threw, so backend state stayed false; manager must mirror.") + } + + func testRefreshReadsBackendState() { + let backend = FakeLaunchItemBackend() + let manager = LaunchAtLoginManager(backend: backend) + XCTAssertFalse(manager.isEnabled) + + backend.isEnabled = true + manager.refresh() + + XCTAssertTrue(manager.isEnabled) + } +} + +// MARK: - Test doubles + +@MainActor +private final class FakeLaunchItemBackend: LaunchItemBackend { + var isEnabled: Bool = false + private(set) var registerCalls = 0 + private(set) var unregisterCalls = 0 + var registerError: Error? + var unregisterError: Error? + + func register() throws { + self.registerCalls += 1 + if let registerError { throw registerError } + self.isEnabled = true + } + + func unregister() throws { + self.unregisterCalls += 1 + if let unregisterError { throw unregisterError } + self.isEnabled = false + } +} + +private enum FakeError: Error { case boom } diff --git a/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift new file mode 100644 index 0000000..bd67fd0 --- /dev/null +++ b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift @@ -0,0 +1,50 @@ +// +// LaunchAtLoginManager.swift +// Caffeine +// + +import Foundation +import Observation + +/// Source of truth for the "Launch at Login" preference. The published +/// `isEnabled` value mirrors the underlying ``LaunchItemBackend``; user-driven +/// changes go through `setEnabled(_:)`. If the user toggles the login item via +/// System Settings instead of the app, call `refresh()` to re-read state. +@MainActor +@Observable +public final class LaunchAtLoginManager { + public static let shared = LaunchAtLoginManager() + + public private(set) var isEnabled: Bool = false + + private let backend: any LaunchItemBackend + + public init(backend: any LaunchItemBackend = SMAppServiceBackend.shared) { + self.backend = backend + self.refresh() + } + + /// Re-reads the current state from the backend. + public func refresh() { + self.isEnabled = self.backend.isEnabled + } + + /// Attempts to enable or disable the login item. + /// - Returns: `true` if the backend call succeeded; `false` if it threw. + @discardableResult + 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 + } +} diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift index 9b9c2d1..492e3e2 100644 --- a/src/Caffeine/Classes/Views/PreferencesView.swift +++ b/src/Caffeine/Classes/Views/PreferencesView.swift @@ -9,6 +9,7 @@ import SwiftUI struct PreferencesView: View { @ObservedObject var viewModel: CaffeineViewModel + @State private var loginManager = LaunchAtLoginManager.shared @AppStorage(PreferenceKeys.defaultDuration) private var defaultDuration = 0 @AppStorage(PreferenceKeys.activateAtLaunch) private var activateAtLaunch = false @AppStorage(PreferenceKeys.suppressLaunchMessage) private var suppressLaunchMessage = false @@ -67,6 +68,12 @@ struct PreferencesView: View { // Checkboxes VStack(alignment: .leading, spacing: 8) { + Toggle("Launch at Login", isOn: Binding( + get: { self.loginManager.isEnabled }, + set: { _ = self.loginManager.setEnabled($0) } + )) + .font(.system(size: 13)) + Toggle("Activate when starting Caffeine", isOn: self.$activateAtLaunch) .font(.system(size: 13)) @@ -121,6 +128,7 @@ struct PreferencesView: View { .padding(.horizontal, 20) .frame(width: 640) .fixedSize(horizontal: false, vertical: true) + .onAppear { self.loginManager.refresh() } } } From 1d4bbadf2fee607b0dfd3150ed601c1fc067490c Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 03:05:35 +0800 Subject: [PATCH 3/8] Allow Mac to run with lid closed; prevent system idle sleep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Package.swift | 1 + .../SleepPreventionManagerTests.swift | 113 +++++++++++++++ scripts/integration-test.sh | 85 ++++++++++++ .../Models/SleepPreventionManager.swift | 131 +++++++++++------- .../ViewModels/CaffeineViewModel.swift | 21 ++- .../Classes/Views/PreferencesView.swift | 15 ++ 6 files changed, 315 insertions(+), 51 deletions(-) create mode 100644 Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift create mode 100755 scripts/integration-test.sh diff --git a/Package.swift b/Package.swift index c5ebb54..ec4b575 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,7 @@ let package = Package( "LaunchAtLoginManager.swift", "LaunchItemBackend.swift", "PowerAssertionBackend.swift", + "SleepPreventionManager.swift", ] ), .testTarget( diff --git a/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift new file mode 100644 index 0000000..993df59 --- /dev/null +++ b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift @@ -0,0 +1,113 @@ +// +// SleepPreventionManagerTests.swift +// CaffeineCoreTests +// + +import XCTest +@testable import CaffeineCore + +@MainActor +final class SleepPreventionManagerTests: XCTestCase { + func testPreventSleepWithLidCloseFalseCreatesDisplayAndSystemIdleAssertions() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + + manager.preventSleep(allowLidClose: false) + + XCTAssertEqual(manager.heldAssertionCount, 2) + let liveTypes = Set(fake.liveAssertions.values) + XCTAssertEqual(liveTypes, ["PreventUserIdleDisplaySleep", "PreventUserIdleSystemSleep"]) + } + + func testPreventSleepWithLidCloseTrueAddsPreventSystemAssertion() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + + manager.preventSleep(allowLidClose: true) + + XCTAssertEqual(manager.heldAssertionCount, 3) + let liveTypes = Set(fake.liveAssertions.values) + XCTAssertEqual( + liveTypes, + ["PreventUserIdleDisplaySleep", "PreventUserIdleSystemSleep", "PreventSystemSleep"] + ) + } + + func testUpdateAllowLidCloseTrueWhileActiveAddsThirdAssertion() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: false) + XCTAssertEqual(manager.heldAssertionCount, 2) + + manager.updateAllowLidClose(true) + + XCTAssertEqual(manager.heldAssertionCount, 3) + XCTAssertTrue(fake.liveAssertions.values.contains("PreventSystemSleep")) + } + + func testUpdateAllowLidCloseFalseWhileActiveRemovesThirdAssertion() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: true) + XCTAssertEqual(manager.heldAssertionCount, 3) + + manager.updateAllowLidClose(false) + + XCTAssertEqual(manager.heldAssertionCount, 2) + XCTAssertFalse(fake.liveAssertions.values.contains("PreventSystemSleep")) + } + + func testUpdateAllowLidCloseWhileInactiveDoesNotCreateAssertions() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + + manager.updateAllowLidClose(true) + + XCTAssertEqual(manager.heldAssertionCount, 0) + XCTAssertTrue(fake.liveAssertions.isEmpty) + } + + func testAllowSleepReleasesAllAssertions() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: true) + XCTAssertEqual(manager.heldAssertionCount, 3) + + manager.allowSleep() + + XCTAssertEqual(manager.heldAssertionCount, 0) + XCTAssertTrue(fake.liveAssertions.isEmpty) + XCTAssertEqual(fake.releaseCalls.count, 3) + } + + func testAssertionTimeoutIsThirtySeconds() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + + manager.preventSleep(allowLidClose: true) + + XCTAssertTrue(fake.createCalls.allSatisfy { $0.timeout == 30 }) + } +} + +// MARK: - Test doubles + +private final class FakePowerAssertionBackend: PowerAssertionBackend, @unchecked Sendable { + private var nextID: UInt32 = 1 + private(set) var liveAssertions: [UInt32: String] = [:] + private(set) var createCalls: [(type: String, reason: String, timeout: TimeInterval)] = [] + private(set) var releaseCalls: [UInt32] = [] + + func create(type: String, reason: String, timeout: TimeInterval) -> UInt32? { + let id = self.nextID + self.nextID += 1 + self.liveAssertions[id] = type + self.createCalls.append((type, reason, timeout)) + return id + } + + func release(_ id: UInt32) { + self.releaseCalls.append(id) + self.liveAssertions.removeValue(forKey: id) + } +} diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..1aea0ab --- /dev/null +++ b/scripts/integration-test.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Integration smoke test: build Caffeine.app, run it briefly with a debug +# auto-activate env var, and verify that `pmset -g assertions` reflects the +# expected IOPMAssertion types. Exits non-zero on any mismatch. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +DERIVED="${DERIVED:-/tmp/caffeine-derived}" +APP="$DERIVED/Build/Products/Debug/Caffeine.app" +BINARY="$APP/Contents/MacOS/Caffeine" + +echo "==> Building Caffeine.app (Debug)" +xcodebuild \ + -project src/Caffeine.xcodeproj \ + -scheme Caffeine \ + -destination 'platform=macOS' \ + -configuration Debug \ + -derivedDataPath "$DERIVED" \ + build CODE_SIGNING_ALLOWED=NO > /tmp/caffeine-xcodebuild.log + +if [[ ! -x "$BINARY" ]]; then + echo "FAIL: built binary not found at $BINARY" + tail -50 /tmp/caffeine-xcodebuild.log + exit 1 +fi + +APP_PID="" +cleanup() { + if [[ -n "$APP_PID" ]]; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + pkill -f "Caffeine.app/Contents/MacOS/Caffeine" 2>/dev/null || true +} +trap cleanup EXIT + +run_case() { + local mode="$1" + local expect_present="$2" + local expect_absent="${3:-}" + + echo "==> Case: CA_TEST_AUTOACTIVATE=$mode" + 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 + + for needle in $expect_present; do + if ! echo "$out" | grep -q "$needle"; then + echo "FAIL: expected assertion type '$needle' not present" + echo "$out" + return 1 + fi + echo " ok: held $needle" + done + + if [[ -n "$expect_absent" ]]; then + if echo "$out" | grep -q "$expect_absent"; then + echo "FAIL: assertion type '$expect_absent' should not be present" + echo "$out" + return 1 + fi + echo " ok: not holding $expect_absent" + fi + + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + APP_PID="" + sleep 1 +} + +run_case "lid-closed" "PreventUserIdleDisplaySleep PreventUserIdleSystemSleep PreventSystemSleep" +run_case "lid-open" "PreventUserIdleDisplaySleep PreventUserIdleSystemSleep" "PreventSystemSleep" + +echo "==> Integration checks passed" diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift index 5c6a692..4c97f78 100644 --- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift +++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift @@ -9,93 +9,124 @@ import AppKit import Foundation import IOKit.pwr_mgt -/// Manages the core functionality of preventing system sleep -final class SleepPreventionManager { - static let shared = SleepPreventionManager() - - private var sleepAssertionID: IOPMAssertionID? +/// Manages the core functionality of preventing system sleep. +/// +/// Holds up to three IOKit power assertions while active: +/// - `PreventUserIdleDisplaySleep` so the display does not dim from inactivity +/// - `PreventUserIdleSystemSleep` so the system does not idle-sleep +/// - `PreventSystemSleep` (only when the lid-close flag is on) so a portable +/// Mac on AC power stays running with the lid closed +/// +/// The assertion timer refreshes every 10 s with a 30 s assertion timeout so +/// the windows always overlap (the previous 8 s timeout left a 2 s gap). +@MainActor +public final class SleepPreventionManager { + public static let shared = SleepPreventionManager() + + private let backend: any PowerAssertionBackend + + private var idleDisplayAssertionID: UInt32? + private var idleSystemAssertionID: UInt32? + private var preventSystemAssertionID: UInt32? private var assertionTimer: Timer? private var isUserSessionActive = true + private var allowLidClose = false + private var isActive = false - private init() { + public init(backend: any PowerAssertionBackend = IOKitPowerAssertionBackend.shared) { + self.backend = backend self.setupWorkspaceNotifications() } - deinit { - releaseSleepAssertion() - assertionTimer?.invalidate() - NotificationCenter.default.removeObserver(self) - } - // MARK: - Public Methods - /// Prevents the system from sleeping - func preventSleep() { - // Start or restart the assertion timer + /// Activates sleep prevention. Pass `allowLidClose: true` to also hold a + /// `PreventSystemSleep` assertion so the Mac stays running with the lid + /// closed (effective only on AC power). + public func preventSleep(allowLidClose: Bool) { + self.allowLidClose = allowLidClose + self.isActive = true self.assertionTimer?.invalidate() self.assertionTimer = Timer.scheduledTimer( withTimeInterval: 10.0, repeats: true ) { [weak self] _ in - self?.refreshSleepAssertion() + Task { @MainActor [weak self] in self?.refreshAssertions() } } - self.assertionTimer?.fire() // Fire immediately + self.refreshAssertions() } - /// Allows the system to sleep normally - func allowSleep() { + /// Updates the lid-close flag while active so the change takes effect on + /// the next refresh without re-activating from scratch. No-op when + /// inactive. + public func updateAllowLidClose(_ value: Bool) { + guard self.allowLidClose != value else { return } + self.allowLidClose = value + if self.isActive { self.refreshAssertions() } + } + + /// Allows the system to sleep normally and releases every held assertion. + public func allowSleep() { self.assertionTimer?.invalidate() self.assertionTimer = nil - self.releaseSleepAssertion() + self.isActive = false + self.releaseAll() } - // MARK: - Private Methods + // MARK: - Test introspection - private func refreshSleepAssertion() { - guard self.isUserSessionActive else { return } + /// Number of assertions currently held by the manager. Exposed for tests. + public var heldAssertionCount: Int { + [self.idleDisplayAssertionID, self.idleSystemAssertionID, self.preventSystemAssertionID] + .compactMap { $0 } + .count + } - // Release existing assertion - if let assertionID = sleepAssertionID { - IOPMAssertionRelease(assertionID) - } + // MARK: - Private Methods - // Create new assertion - var assertionID: IOPMAssertionID = 0 - let reason = String(localized: "Caffeine prevents sleep") as CFString - let result = IOPMAssertionCreateWithDescription( - kIOPMAssertPreventUserIdleDisplaySleep as CFString, - reason, - nil as CFString?, - nil as CFString?, - nil as CFString?, - 8, // Timeout after 8 seconds - nil as CFString?, - &assertionID + 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 + ) } } - private func releaseSleepAssertion() { - if let assertionID = sleepAssertionID { - IOPMAssertionRelease(assertionID) - self.sleepAssertionID = nil - } + private func releaseAll() { + if let id = idleDisplayAssertionID { self.backend.release(id) } + if let id = idleSystemAssertionID { self.backend.release(id) } + if let id = preventSystemAssertionID { self.backend.release(id) } + self.idleDisplayAssertionID = nil + self.idleSystemAssertionID = nil + self.preventSystemAssertionID = nil } private func setupWorkspaceNotifications() { - let notificationCenter = NSWorkspace.shared.notificationCenter + let nc = NSWorkspace.shared.notificationCenter - notificationCenter.addObserver( + nc.addObserver( self, selector: #selector(self.sessionDidResignActive), name: NSWorkspace.sessionDidResignActiveNotification, object: nil ) - notificationCenter.addObserver( + nc.addObserver( self, selector: #selector(self.sessionDidBecomeActive), name: NSWorkspace.sessionDidBecomeActiveNotification, diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift index 407c4d1..f27b2be 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -33,6 +33,17 @@ class CaffeineViewModel: ObservableObject { self.setupObservers() + #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 + // Check if we should activate at launch if UserDefaults.standard.bool(forKey: PreferenceKeys.activateAtLaunch) { self.activate() @@ -108,13 +119,20 @@ class CaffeineViewModel: ObservableObject { } self.isActive = true - SleepPreventionManager.shared.preventSleep() + let allowLidClose = UserDefaults.standard.bool(forKey: PreferenceKeys.allowLidClose) + SleepPreventionManager.shared.preventSleep(allowLidClose: allowLidClose) if UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) { ActivitySimulator.shared.startMonitoring() } } + /// Updates the lid-close flag and applies it live if currently active. + func setAllowLidClose(_ enabled: Bool) { + UserDefaults.standard.set(enabled, forKey: PreferenceKeys.allowLidClose) + SleepPreventionManager.shared.updateAllowLidClose(enabled) + } + /// Deactivates Caffeine func deactivate() { self.cancelTimers() @@ -212,4 +230,5 @@ enum PreferenceKeys { static let suppressLaunchMessage = "CASuppressLaunchMessage" static let deactivateOnManualSleep = "CADeactivateOnManualSleep" static let keepAppsActive = "CAKeepAppsActive" + static let allowLidClose = "CAAllowLidClose" } diff --git a/src/Caffeine/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift index 492e3e2..f9d0cf4 100644 --- a/src/Caffeine/Classes/Views/PreferencesView.swift +++ b/src/Caffeine/Classes/Views/PreferencesView.swift @@ -15,6 +15,7 @@ struct PreferencesView: View { @AppStorage(PreferenceKeys.suppressLaunchMessage) private var suppressLaunchMessage = false @AppStorage(PreferenceKeys.deactivateOnManualSleep) private var deactivateOnManualSleep = false @AppStorage(PreferenceKeys.keepAppsActive) private var keepAppsActive = false + @AppStorage(PreferenceKeys.allowLidClose) private var allowLidClose = false var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -86,6 +87,20 @@ struct PreferencesView: View { )) .font(.system(size: 13)) + 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)) + + Text("Works on AC power. On battery, macOS may still sleep when the lid is closed.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.leading, 20) + Divider() .padding(.vertical, 4) From 671fece90c33bde0934ecdd96cf700b40982c0cd Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 03:09:59 +0800 Subject: [PATCH 4/8] Add Traditional Chinese localization and new preference strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../CaffeineCoreTests/LocalizationTests.swift | 109 ++++++++++++++++++ .../Resources/de.lproj/Localizable.strings | 3 + .../Resources/en.lproj/Localizable.strings | 3 + .../Resources/es.lproj/Localizable.strings | 3 + .../Resources/fr.lproj/Localizable.strings | 3 + .../Resources/it.lproj/Localizable.strings | 3 + .../Resources/ja.lproj/Localizable.strings | 3 + .../Resources/ko.lproj/Localizable.strings | 3 + .../Resources/nl.lproj/Localizable.strings | 3 + .../Resources/pt-BR.lproj/Localizable.strings | 3 + .../Resources/pt.lproj/Localizable.strings | 3 + .../Resources/ru.lproj/Localizable.strings | 3 + .../Resources/uk.lproj/Localizable.strings | 3 + .../zh-Hans.lproj/Localizable.strings | 3 + .../zh-Hant.lproj/Localizable.strings | 47 ++++++++ 15 files changed, 195 insertions(+) create mode 100644 Tests/CaffeineCoreTests/LocalizationTests.swift create mode 100644 src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings diff --git a/Tests/CaffeineCoreTests/LocalizationTests.swift b/Tests/CaffeineCoreTests/LocalizationTests.swift new file mode 100644 index 0000000..9585f82 --- /dev/null +++ b/Tests/CaffeineCoreTests/LocalizationTests.swift @@ -0,0 +1,109 @@ +// +// LocalizationTests.swift +// CaffeineCoreTests +// +// Verifies that every shipped .lproj/Localizable.strings file parses and +// contains the full key set, so any new user-facing string forces every +// locale to be updated. +// + +import Foundation +import XCTest + +final class LocalizationTests: XCTestCase { + /// Path to `src/Caffeine/Resources` resolved relative to this test source + /// file so tests work no matter where the repo is checked out. + private var resourcesURL: URL { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // CaffeineCoreTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // repo root + .appendingPathComponent("src/Caffeine/Resources") + } + + /// Locales that ship in the app bundle. + private let expectedLocales = [ + "de", "en", "es", "fr", "it", "ja", "ko", + "nl", "pt", "pt-BR", "ru", "uk", "zh-Hans", "zh-Hant", + ] + + /// Every key that PreferencesView, MenuBarController, or the model layer + /// looks up via `String(localized:)`. Failure to find any of these in any + /// locale is a build-break. + private let expectedKeys: Set = [ + // Activation durations + "1 minute", "5 minutes", "10 minutes", "15 minutes", "30 minutes", + "1 hour", "2 hours", "5 hours", "Indefinitely", + // Status messages + "Caffeine is active", "%d minutes", "%d seconds", + // Menu items + "Check for Updates...", "Preferences...", "About Caffeine", "Quit", + // Preferences instructions + "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.", + "Right-click (or ⌃-click) the menu bar icon to show the Caffeine menu.", + // Preferences labels + "Default duration:", + "Activate when starting Caffeine", + "Deactivate when device goes to sleep manually", + "Show this message when starting Caffeine", + "Keep apps active", + "Prevents apps from becoming inactive and the screen saver from starting.", + "Launch at Login", + "Allow Mac to run with lid closed", + "Works on AC power. On battery, macOS may still sleep when the lid is closed.", + "Close", + // Menu additions + "Activate for", "Welcome to Caffeine", + // About credits + "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net", + // System messages + "Caffeine prevents sleep", + ] + + func testEveryLocaleParsesAndContainsAllKeys() throws { + for locale in self.expectedLocales { + let stringsURL = self.resourcesURL + .appendingPathComponent("\(locale).lproj") + .appendingPathComponent("Localizable.strings") + + guard FileManager.default.fileExists(atPath: stringsURL.path) else { + XCTFail("Missing Localizable.strings for \(locale) at \(stringsURL.path)") + continue + } + + guard let dict = NSDictionary(contentsOf: stringsURL) as? [String: String] else { + XCTFail("\(locale): failed to parse Localizable.strings as [String: String]") + continue + } + + let actualKeys = Set(dict.keys) + let missing = self.expectedKeys.subtracting(actualKeys) + XCTAssertTrue( + missing.isEmpty, + "\(locale) is missing \(missing.count) key(s): \(missing.sorted())" + ) + } + } + + func testTraditionalChineseLocaleExistsAndIsDistinctFromSimplified() throws { + let hantURL = self.resourcesURL + .appendingPathComponent("zh-Hant.lproj") + .appendingPathComponent("Localizable.strings") + let hansURL = self.resourcesURL + .appendingPathComponent("zh-Hans.lproj") + .appendingPathComponent("Localizable.strings") + + guard + let hant = NSDictionary(contentsOf: hantURL) as? [String: String], + let hans = NSDictionary(contentsOf: hansURL) as? [String: String] + else { + XCTFail("zh-Hant or zh-Hans failed to parse") + return + } + + XCTAssertEqual(hant.keys.sorted(), hans.keys.sorted(), "zh-Hant must cover the same keys as zh-Hans") + // 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") + } +} diff --git a/src/Caffeine/Resources/de.lproj/Localizable.strings b/src/Caffeine/Resources/de.lproj/Localizable.strings index fddf93f..9ba5faf 100644 --- a/src/Caffeine/Resources/de.lproj/Localizable.strings +++ b/src/Caffeine/Resources/de.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Diese Nachricht beim Start von Caffeine anzeigen"; "Keep apps active" = "Apps aktiv halten"; "Prevents apps from becoming inactive and the screen saver from starting." = "Verhindert, dass Apps inaktiv werden und der Bildschirmschoner startet."; +"Launch at Login" = "Beim Anmelden starten"; +"Allow Mac to run with lid closed" = "Mac mit geschlossenem Deckel weiterlaufen lassen"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Funktioniert nur am Netzteil. Im Akkubetrieb kann macOS bei geschlossenem Deckel trotzdem in den Ruhezustand wechseln."; "Close" = "Schließen"; /* Menu additions */ diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings index 66e5e4c..ee1a275 100644 --- a/src/Caffeine/Resources/en.lproj/Localizable.strings +++ b/src/Caffeine/Resources/en.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Show this message when starting Caffeine"; "Keep apps active" = "Keep apps active"; "Prevents apps from becoming inactive and the screen saver from starting." = "Prevents apps from becoming inactive and the screen saver from starting."; +"Launch at Login" = "Launch at Login"; +"Allow Mac to run with lid closed" = "Allow Mac to run with lid closed"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Works on AC power. On battery, macOS may still sleep when the lid is closed."; "Close" = "Close"; /* Menu additions */ diff --git a/src/Caffeine/Resources/es.lproj/Localizable.strings b/src/Caffeine/Resources/es.lproj/Localizable.strings index e708b41..bda292b 100644 --- a/src/Caffeine/Resources/es.lproj/Localizable.strings +++ b/src/Caffeine/Resources/es.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Mostrar este mensaje al iniciar Caffeine"; "Keep apps active" = "Mantener apps activas"; "Prevents apps from becoming inactive and the screen saver from starting." = "Evita que las apps se vuelvan inactivas y que se inicie el salvapantallas."; +"Launch at Login" = "Iniciar al iniciar sesión"; +"Allow Mac to run with lid closed" = "Permitir que el Mac funcione con la tapa cerrada"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Solo funciona con corriente eléctrica. Con batería, macOS puede dormir cuando se cierra la tapa."; "Close" = "Cerrar"; /* Menu additions */ diff --git a/src/Caffeine/Resources/fr.lproj/Localizable.strings b/src/Caffeine/Resources/fr.lproj/Localizable.strings index e1388d1..f0c05bc 100644 --- a/src/Caffeine/Resources/fr.lproj/Localizable.strings +++ b/src/Caffeine/Resources/fr.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Afficher ce message au lancement de Caffeine"; "Keep apps active" = "Garder les apps actives"; "Prevents apps from becoming inactive and the screen saver from starting." = "Empêche les apps de devenir inactives et l'économiseur d'écran de démarrer."; +"Launch at Login" = "Lancer à l'ouverture de session"; +"Allow Mac to run with lid closed" = "Autoriser le Mac à fonctionner capot fermé"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Fonctionne uniquement sur secteur. Sur batterie, macOS peut se mettre en veille lorsque le capot est fermé."; "Close" = "Fermer"; /* Menu additions */ diff --git a/src/Caffeine/Resources/it.lproj/Localizable.strings b/src/Caffeine/Resources/it.lproj/Localizable.strings index 69891b9..8643120 100644 --- a/src/Caffeine/Resources/it.lproj/Localizable.strings +++ b/src/Caffeine/Resources/it.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Mostra questo messaggio all'avvio di Caffeine"; "Keep apps active" = "Mantieni le app attive"; "Prevents apps from becoming inactive and the screen saver from starting." = "Impedisce alle app di diventare inattive e l'avvio del salvaschermo."; +"Launch at Login" = "Avvia all'accesso"; +"Allow Mac to run with lid closed" = "Consenti al Mac di funzionare con il coperchio chiuso"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Funziona solo con alimentazione di rete. Con la batteria, macOS può andare in stop quando il coperchio è chiuso."; "Close" = "Chiudi"; /* Menu additions */ diff --git a/src/Caffeine/Resources/ja.lproj/Localizable.strings b/src/Caffeine/Resources/ja.lproj/Localizable.strings index c020ed0..bcbe718 100644 --- a/src/Caffeine/Resources/ja.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ja.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Caffeine 起動時にこのメッセージを表示"; "Keep apps active" = "アプリをアクティブに保つ"; "Prevents apps from becoming inactive and the screen saver from starting." = "アプリが非アクティブになるのを防ぎ、スクリーンセーバーの起動を防止します。"; +"Launch at Login" = "ログイン時に起動"; +"Allow Mac to run with lid closed" = "蓋を閉じた状態でも実行を維持"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "電源接続時のみ有効です。バッテリー駆動時は蓋を閉じると macOS がスリープすることがあります。"; "Close" = "閉じる"; /* Menu additions */ diff --git a/src/Caffeine/Resources/ko.lproj/Localizable.strings b/src/Caffeine/Resources/ko.lproj/Localizable.strings index f05d37e..3f045b6 100644 --- a/src/Caffeine/Resources/ko.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ko.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Caffeine 시작 시 이 메시지 표시"; "Keep apps active" = "앱 활성 상태 유지"; "Prevents apps from becoming inactive and the screen saver from starting." = "앱이 비활성 상태가 되거나 화면 보호기가 시작되는 것을 방지합니다."; +"Launch at Login" = "로그인 시 시작"; +"Allow Mac to run with lid closed" = "덮개를 닫아도 실행 유지"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "전원이 연결된 경우에만 작동합니다. 배터리 사용 시에는 덮개를 닫으면 macOS가 잠들 수 있습니다."; "Close" = "닫기"; /* Menu additions */ diff --git a/src/Caffeine/Resources/nl.lproj/Localizable.strings b/src/Caffeine/Resources/nl.lproj/Localizable.strings index ce3caa7..92521fe 100644 --- a/src/Caffeine/Resources/nl.lproj/Localizable.strings +++ b/src/Caffeine/Resources/nl.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Dit bericht tonen bij het starten van Caffeine"; "Keep apps active" = "Apps actief houden"; "Prevents apps from becoming inactive and the screen saver from starting." = "Voorkomt dat apps inactief worden en dat de schermbeveiliging start."; +"Launch at Login" = "Starten bij aanmelden"; +"Allow Mac to run with lid closed" = "Mac met gesloten deksel laten draaien"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Werkt alleen op netvoeding. Op batterij kan macOS in slaap gaan wanneer het deksel gesloten is."; "Close" = "Sluiten"; /* Menu additions */ diff --git a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings index f9764e8..b4afa98 100644 --- a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Mostrar esta mensagem ao iniciar o Caffeine"; "Keep apps active" = "Manter apps ativos"; "Prevents apps from becoming inactive and the screen saver from starting." = "Impede que os apps fiquem inativos e que a proteção de tela seja iniciada."; +"Launch at Login" = "Iniciar ao fazer login"; +"Allow Mac to run with lid closed" = "Permitir que o Mac funcione com a tampa fechada"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Funciona apenas na tomada. Na bateria, o macOS pode adormecer quando a tampa for fechada."; "Close" = "Fechar"; /* Menu additions */ diff --git a/src/Caffeine/Resources/pt.lproj/Localizable.strings b/src/Caffeine/Resources/pt.lproj/Localizable.strings index 53c6852..63cb843 100644 --- a/src/Caffeine/Resources/pt.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Mostrar esta mensagem ao iniciar o Caffeine"; "Keep apps active" = "Manter apps ativas"; "Prevents apps from becoming inactive and the screen saver from starting." = "Impede que as apps fiquem inativas e que a proteção de ecrã seja iniciada."; +"Launch at Login" = "Iniciar ao iniciar sessão"; +"Allow Mac to run with lid closed" = "Permitir que o Mac funcione com a tampa fechada"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Apenas funciona ligado à corrente. Com bateria, o macOS pode adormecer quando a tampa for fechada."; "Close" = "Fechar"; /* Menu additions */ diff --git a/src/Caffeine/Resources/ru.lproj/Localizable.strings b/src/Caffeine/Resources/ru.lproj/Localizable.strings index a83a207..6518e44 100644 --- a/src/Caffeine/Resources/ru.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ru.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Показывать это сообщение при запуске Caffeine"; "Keep apps active" = "Поддерживать активность приложений"; "Prevents apps from becoming inactive and the screen saver from starting." = "Предотвращает переход приложений в неактивный режим и запуск заставки."; +"Launch at Login" = "Запускать при входе в систему"; +"Allow Mac to run with lid closed" = "Разрешить работу при закрытой крышке"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Работает только от сети. От аккумулятора macOS может уснуть при закрытии крышки."; "Close" = "Закрыть"; /* Menu additions */ diff --git a/src/Caffeine/Resources/uk.lproj/Localizable.strings b/src/Caffeine/Resources/uk.lproj/Localizable.strings index b1cd894..791beda 100644 --- a/src/Caffeine/Resources/uk.lproj/Localizable.strings +++ b/src/Caffeine/Resources/uk.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "Показувати це повідомлення при запуску Caffeine"; "Keep apps active" = "Тримати застосунки активними"; "Prevents apps from becoming inactive and the screen saver from starting." = "Не дає застосункам переходити в неактивний режим і запускатися заставці."; +"Launch at Login" = "Запускати під час входу"; +"Allow Mac to run with lid closed" = "Дозволити роботу із закритою кришкою"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "Працює лише від мережі. Від акумулятора macOS може заснути після закриття кришки."; "Close" = "Закрити"; /* Menu additions */ diff --git a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings index 768aca3..fd90329 100644 --- a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings +++ b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings @@ -31,6 +31,9 @@ "Show this message when starting Caffeine" = "启动 Caffeine 时显示此消息"; "Keep apps active" = "保持应用活跃"; "Prevents apps from becoming inactive and the screen saver from starting." = "防止应用变为非活跃状态并阻止屏幕保护程序启动。"; +"Launch at Login" = "登录时启动"; +"Allow Mac to run with lid closed" = "合盖时保持运行"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "仅在接通电源时生效。使用电池时,macOS 仍可能在合盖后进入睡眠。"; "Close" = "关闭"; /* Menu additions */ diff --git a/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings b/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 0000000..17c5d77 --- /dev/null +++ b/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,47 @@ +/* Activation durations */ +"1 minute" = "1 分鐘"; +"5 minutes" = "5 分鐘"; +"10 minutes" = "10 分鐘"; +"15 minutes" = "15 分鐘"; +"30 minutes" = "30 分鐘"; +"1 hour" = "1 小時"; +"2 hours" = "2 小時"; +"5 hours" = "5 小時"; +"Indefinitely" = "無限期"; + +/* Status messages */ +"Caffeine is active" = "Caffeine 已啟用"; +"%d minutes" = "%d 分鐘"; +"%d seconds" = "%d 秒"; + +/* Menu items */ +"Check for Updates..." = "檢查更新…"; +"Preferences..." = "偏好設定…"; +"About Caffeine" = "關於 Caffeine"; +"Quit" = "結束"; + +/* Preferences instructions */ +"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 選單。"; + +/* Preferences labels */ +"Default duration:" = "預設時長:"; +"Activate when starting Caffeine" = "啟動 Caffeine 時自動啟用"; +"Deactivate when device goes to sleep manually" = "手動進入睡眠時停用"; +"Show this message when starting Caffeine" = "啟動 Caffeine 時顯示此訊息"; +"Keep apps active" = "保持 App 活躍"; +"Prevents apps from becoming inactive and the screen saver from starting." = "防止 App 變為非活躍狀態,並阻止螢幕保護程式啟動。"; +"Launch at Login" = "登入時啟動"; +"Allow Mac to run with lid closed" = "闔蓋時保持運作"; +"Works on AC power. On battery, macOS may still sleep when the lid is closed." = "僅在接通電源時生效。使用電池時,macOS 仍可能在闔蓋後進入睡眠。"; +"Close" = "關閉"; + +/* Menu additions */ +"Activate for" = "啟用時長"; +"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"; + +/* System messages */ +"Caffeine prevents sleep" = "Caffeine 防止睡眠"; From 8479dbc5be8d02e3f194c6ca5f3682b4232448a9 Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 03:11:31 +0800 Subject: [PATCH 5/8] Rewrite README; add Traditional Chinese tutorial; update CHANGELOG 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. --- CHANGELOG.md | 12 +++++ README.md | 108 +++++++++++++++++++++++++++++++++++---------- README.zh-Hant.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 README.zh-Hant.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e3713..38f4bf2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- "Launch at Login" preference, backed by `SMAppService.mainApp`. +- "Allow Mac to run with lid closed" preference. When enabled, Caffeine additionally holds a `kIOPMAssertionTypePreventSystemSleep` assertion so a portable Mac on AC power keeps running with the lid closed. +- Traditional Chinese (zh-Hant) localization. +- Traditional Chinese README (`README.zh-Hant.md`) with a step-by-step tutorial for the two new toggles. +- Swift Package (`Package.swift`) + `swift test` unit tests covering the new launch-item and power-assertion wiring. +- `scripts/integration-test.sh` that builds the app and verifies `pmset -g assertions` reflects the expected types. + ### Changed +- Sleep prevention now also holds `kIOPMAssertPreventUserIdleSystemSleep` (previously display-idle only), so the whole system stays awake during idle — not just the display. +- Bumped the IOPMAssertion timeout from 8 s to 30 s so the 10 s refresh window always overlaps (the previous values left a 2 s gap every cycle). +- Rewrote `README.md` to point at this fork's releases and issue tracker; removed third-party support and download URLs. - Improved Ukrainian translation. ### Fixed diff --git a/README.md b/README.md index e48715d..062ee68 100755 --- a/README.md +++ b/README.md @@ -1,46 +1,110 @@ -Icon +Caffeine cup icon # Caffeine -### Don't let your Mac fall asleep. -Caffeine is a tiny program that keeps your Mac awake, useful for ensuring that long running tasks aren't interrupted by your computer going to sleep. +### Keep your Mac awake — including with the lid closed. -### Installation +[English](README.md) • [繁體中文](README.zh-Hant.md) -Download Caffeine at https://caffeine-app.net and drag it into your Applications folder, then double-click the icon to launch it. +Caffeine is a small menu-bar utility that prevents your Mac from going to sleep, dimming the display, or starting the screensaver. This fork of [`domzilla/Caffeine`](https://github.com/domzilla/Caffeine) adds an **"Allow Mac to run with lid closed"** option (Amphetamine-parity on AC power) and a **"Launch at Login"** toggle, plus Traditional Chinese localization. -### Usage +--- -Caffeine puts a coffee cup icon in the right side of your menu bar. Click the cup to toggle whether Caffeine is active or not -- a full cup means Caffeine will prevent your Mac from automatically going to sleep, dimming the screen or starting screen savers. An empty cup means your Mac will sleep normally. +## Quickstart -Menubar +1. Download the latest `Caffeine.app` from the [Releases](https://github.com/bubbleee030/Caffeine/releases) page. +2. Drag it into `~/Applications` (or `/Applications`). +3. Double-click to launch. A coffee-cup icon appears at the right side of the menu bar. +4. **Click** the cup to toggle sleep prevention. **Right-click** (or ⌃-click) for the menu. -For more control, right-click (or ⌘-click) the icon to show the menu. From here, you can access the preferences window or set a timeout if you only need Caffeine to prevent sleep for a little while. +## Features -Menu +| Feature | What it does | +| --- | --- | +| **Toggle** | Click the menu-bar cup. A full cup = active. An empty cup = your Mac sleeps normally. | +| **Timed activation** | Right-click → *Activate for* → pick 5 min ‥ 5 hours, or *Indefinitely*. | +| **Launch at Login** *(new)* | Preferences → *Launch at Login*. Uses `SMAppService`, sandbox-safe, no helper. | +| **Allow Mac to run with lid closed** *(new)* | Preferences → *Allow Mac to run with lid closed*. Holds an additional `PreventSystemSleep` IOPMAssertion so a portable Mac stays running with the lid closed **on AC power**. On battery, macOS may still sleep — this is enforced by the system. | +| **Keep apps active** | Simulates HID activity so apps like Teams or Slack stop marking you as "Away". | +| **Deactivate on manual sleep** | Stops Caffeine when you put your Mac to sleep via the Apple menu. | -Caffeine is intended to be simple, yet powerful. Options you can configure include whether to start Caffeine automatically every time you start up your Mac, whether Caffeine should activate every time it starts, and a default duration if you always want Caffeine to turn itself off after a set time. +## Tutorial: the two new toggles -Preferences +### Launch at Login -### FAQ +Open Preferences (right-click cup → *Preferences…*) and switch on **Launch at Login**. macOS registers Caffeine as a login item; you can also see it under **System Settings → General → Login Items & Extensions**. Toggling either side keeps the state in sync — Caffeine re-reads `SMAppService.mainApp.status` when its Preferences window opens. -##### Is this the same Caffeine that I've used before? +### Allow Mac to run with lid closed -Yes! Tomas Franzén of Lighthead Software originally developed Caffeine in 2006, and it has been a well known and loved utility for Mac users for many years. Its simplicity has allowed it to continue working perfectly long after active development had ceased. +Switch on **Allow Mac to run with lid closed**, then activate Caffeine (click the cup). On AC power, you can now close the lid and the Mac will keep running — useful for downloads, long renders, or streaming to an external display while the laptop is shut. -In 2018, Michael Jones (IntelliScape Computer Solutions) reached out to Tomas to inquire if they could continue development of Caffeine. +To verify it's working, in Terminal: -Tomas has graciously provided the source code under an open source license, allowing IntelliScape Computer Solutions to continue developing Caffeine where he left off. +```bash +pmset -g assertions | grep -i caffeine +``` + +You should see all three assertion types while active with the toggle on: + +``` +pid 12345(Caffeine): … PreventUserIdleDisplaySleep … +pid 12345(Caffeine): … PreventUserIdleSystemSleep … +pid 12345(Caffeine): … PreventSystemSleep … +``` + +If you turn the lid-close toggle off, the third line disappears. + +> ⚠️ macOS's closed-lid sleep policy is enforced by the kernel. On battery, the system may still sleep when the lid closes regardless of any assertion. Connect to power for reliable lid-closed operation. + +## Languages + +Caffeine ships with 14 localizations: + +> English · 繁體中文 · 简体中文 · 日本語 · 한국어 · Deutsch · Español · Français · Italiano · Nederlands · Português (BR) · Português (PT) · Русский · Українська + +The European and Slavic translations of the two new strings are best-effort and welcome native-speaker review — open an issue or PR if a phrasing reads oddly in your language. + +## Requirements + +- macOS 14.6 (Sonoma) or later +- Apple silicon or Intel + +## Building from source + +```bash +git clone git@github.com:bubbleee030/Caffeine.git +cd Caffeine +xcodebuild -project src/Caffeine.xcodeproj -scheme Caffeine \ + -destination 'platform=macOS' build CODE_SIGNING_ALLOWED=NO +swift test # runs the unit tests for the manager classes +scripts/integration-test.sh # builds and verifies pmset assertion types +``` + +## FAQ + +##### Is this the same Caffeine I've used before? + +Yes — it's a feature fork. Tomas Franzén shipped the original in 2006, Michael Jones (IntelliScape) revived it in 2018 under an open-source license, and Dominic Rodemer ([domzilla/Caffeine](https://github.com/domzilla/Caffeine)) rewrote it in SwiftUI in 2025. This fork adds Launch at Login and lid-close support on top of that. ##### Does this work with macOS 10.x? -No, this version requires at least macOS 11 (Big Sur). Caffeine for macOS Yosemite or later (including Catalina) is available at: https://www.intelliscapesolutions.com/apps/caffeine/ +No, this version requires at least macOS 14.6 (Sonoma). The upstream `domzilla/Caffeine` builds against macOS 11+; older systems are unsupported. + +##### How is Caffeine different or better than alternatives (such as Amphetamine, KeepingYouAwake, etc.)? + +The point of this fork is to bring Caffeine's signature simplicity *plus* Amphetamine's lid-close behaviour into one menu-bar app. If you already love Amphetamine's session/trigger system, stick with it. If you want a one-click cup with a tiny preferences pane that also keeps your laptop running with the lid shut, this is for you. + +## Support + +Found a bug or have a feature request? Open an issue on GitHub: -##### How is Caffeine different or better than alternatives (such as Amphetamine, KeepingYouAwake, etc)? +> **** -Due to the long period of inactivity for Caffeine, a lot of different and great options have been developed. While the alternatives are great apps and definitely worth your consideration, we believe that Caffeine's power lies in its simplicity and ease of use. +## Credits -### Support +- © 2006 **Tomas Franzén** — original Caffeine +- © 2018 **Michael Jones** (IntelliScape) — revived and open-sourced +- © 2022 **Dominic Rodemer** — SwiftUI rewrite, Sparkle updates, multi-language work +- 2026 **[@bubbleee030](https://github.com/bubbleee030)** — Launch at Login, lid-close support, Traditional Chinese -If you have questions, comments or other feedback get in touch at https://caffeine-app.net/support. \ No newline at end of file +See [`CHANGELOG.md`](CHANGELOG.md) for the full version history. diff --git a/README.zh-Hant.md b/README.zh-Hant.md new file mode 100644 index 0000000..0e54197 --- /dev/null +++ b/README.zh-Hant.md @@ -0,0 +1,110 @@ +Caffeine 咖啡杯圖示 + +# Caffeine + +### 讓你的 Mac 保持清醒 — 連闔上蓋子也行。 + +[English](README.md) • [繁體中文](README.zh-Hant.md) + +Caffeine 是一個小巧的選單列工具,可以防止 Mac 進入睡眠、暗螢幕或啟動螢幕保護程式。本 fork(基於 [`domzilla/Caffeine`](https://github.com/domzilla/Caffeine))新增了 **「闔蓋時保持運作」**(在接通電源時可達到 Amphetamine 等價效果)與 **「登入時啟動」** 開關,並補上繁體中文在地化。 + +--- + +## 快速開始 + +1. 從 [Releases](https://github.com/bubbleee030/Caffeine/releases) 頁面下載最新的 `Caffeine.app`。 +2. 拖到 `~/Applications` 或 `/Applications`。 +3. 點兩下啟動,選單列右側會出現一個咖啡杯圖示。 +4. **點按** 杯子切換啟用狀態,**按右鍵**(或 ⌃-點按)顯示完整選單。 + +## 功能 + +| 功能 | 說明 | +| --- | --- | +| **切換啟用** | 點按選單列的咖啡杯。滿杯=啟用中,空杯=Mac 正常睡眠。 | +| **計時啟用** | 右鍵選單 → *啟用時長* → 選擇 5 分鐘 ‥ 5 小時,或 *無限期*。 | +| **登入時啟動** *(新)* | 偏好設定 → *登入時啟動*。使用 `SMAppService`,App Sandbox 友善,不需 helper bundle。 | +| **闔蓋時保持運作** *(新)* | 偏好設定 → *闔蓋時保持運作*。額外建立 `PreventSystemSleep` 的 IOPMAssertion,讓筆電在 **接通電源** 時闔上蓋子也保持運作。電池模式下,macOS 仍會強制睡眠 — 這是系統層面決定的。 | +| **保持 App 活躍** | 模擬 HID 活動,避免 Teams、Slack 等把你標為「離開」。 | +| **手動睡眠時停用** | 從 Apple 選單手動進入睡眠時停止 Caffeine。 | + +## 教學:兩個新開關 + +### 登入時啟動 + +打開偏好設定(右鍵杯子 → *偏好設定…*),開啟 **登入時啟動**。macOS 會將 Caffeine 註冊為登入項目;你也可以在 **系統設定 → 一般 → 登入項目與擴充功能** 中看到它。從任一邊切換狀態都會同步 — 偏好設定視窗開啟時 Caffeine 會重新讀取 `SMAppService.mainApp.status`。 + +### 闔蓋時保持運作 + +開啟 **闔蓋時保持運作**,然後啟用 Caffeine(點杯子)。接通電源時,你可以闔上蓋子,Mac 會繼續執行 — 適合下載中、長時間轉檔,或把筆電當主機接外接螢幕時闔起來放著。 + +要確認有沒有真的生效,可以在 Terminal 執行: + +```bash +pmset -g assertions | grep -i caffeine +``` + +啟用且開關開啟時,應該會看到三條: + +``` +pid 12345(Caffeine): … PreventUserIdleDisplaySleep … +pid 12345(Caffeine): … PreventUserIdleSystemSleep … +pid 12345(Caffeine): … PreventSystemSleep … +``` + +把闔蓋開關關掉,第三條就會消失。 + +> ⚠️ macOS 的「闔蓋即睡眠」策略是 kernel 強制執行的。電池模式下,無論任何 IOPMAssertion,系統仍可能在闔蓋後進入睡眠。要可靠地闔蓋運作,請接上電源。 + +## 支援的語言 + +Caffeine 內建 14 種語系: + +> English · 繁體中文 · 简体中文 · 日本語 · 한국어 · Deutsch · Español · Français · Italiano · Nederlands · Português (BR) · Português (PT) · Русский · Українська + +歐語與斯拉夫語的兩個新字串為盡力翻譯,歡迎母語人士透過 issue 或 PR 校稿。 + +## 系統需求 + +- macOS 14.6(Sonoma)或更新版本 +- Apple Silicon 或 Intel 處理器 + +## 從原始碼建構 + +```bash +git clone git@github.com:bubbleee030/Caffeine.git +cd Caffeine +xcodebuild -project src/Caffeine.xcodeproj -scheme Caffeine \ + -destination 'platform=macOS' build CODE_SIGNING_ALLOWED=NO +swift test # 跑 manager 類別的單元測試 +scripts/integration-test.sh # 建構並驗證 pmset assertion 類型 +``` + +## 常見問題 + +##### 這是我以前用過的那個 Caffeine 嗎? + +是 — 這是它的功能 fork。Tomas Franzén 在 2006 年釋出最初版本,2018 年由 Michael Jones(IntelliScape)以開源授權復活,2025 年 Dominic Rodemer([domzilla/Caffeine](https://github.com/domzilla/Caffeine))用 SwiftUI 重寫。本 fork 在這個基礎上加入登入時啟動與闔蓋支援。 + +##### 可以在 macOS 10.x 上跑嗎? + +不行,本版本需要 macOS 14.6(Sonoma)或更新。上游 `domzilla/Caffeine` 支援 macOS 11+;更舊的系統不再支援。 + +##### 跟 Amphetamine、KeepingYouAwake 這些有什麼不一樣? + +本 fork 的重點是保留 Caffeine 一貫的簡潔,再加上 Amphetamine 的闔蓋功能。如果你喜歡 Amphetamine 的工作階段/觸發器系統,那個比較適合。如果你只想要一個點一下就清醒的咖啡杯、再加上闔蓋繼續跑,那就是本作。 + +## 支援 + +發現 bug 或想許願功能?請到 GitHub 開 issue: + +> **** + +## 致謝 + +- © 2006 **Tomas Franzén** — 最初的 Caffeine +- © 2018 **Michael Jones**(IntelliScape)— 復活並開源 +- © 2022 **Dominic Rodemer** — SwiftUI 重寫、Sparkle 更新、多語系 +- 2026 **[@bubbleee030](https://github.com/bubbleee030)** — 登入時啟動、闔蓋支援、繁體中文 + +完整版本歷史見 [`CHANGELOG.md`](CHANGELOG.md)。 From d023dd7807ffed33fd901a772a22950d3b0604c2 Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 10:34:25 +0800 Subject: [PATCH 6/8] Apply swiftformat --- .../CaffeineCoreTests/LocalizationTests.swift | 20 +++++++++++-------- .../Models/SleepPreventionManager.swift | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Tests/CaffeineCoreTests/LocalizationTests.swift b/Tests/CaffeineCoreTests/LocalizationTests.swift index 9585f82..a762da0 100644 --- a/Tests/CaffeineCoreTests/LocalizationTests.swift +++ b/Tests/CaffeineCoreTests/LocalizationTests.swift @@ -15,9 +15,9 @@ final class LocalizationTests: XCTestCase { /// file so tests work no matter where the repo is checked out. private var resourcesURL: URL { URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() // CaffeineCoreTests/ - .deletingLastPathComponent() // Tests/ - .deletingLastPathComponent() // repo root + .deletingLastPathComponent() // CaffeineCoreTests/ + .deletingLastPathComponent() // Tests/ + .deletingLastPathComponent() // repo root .appendingPathComponent("src/Caffeine/Resources") } @@ -60,7 +60,7 @@ final class LocalizationTests: XCTestCase { "Caffeine prevents sleep", ] - func testEveryLocaleParsesAndContainsAllKeys() throws { + func testEveryLocaleParsesAndContainsAllKeys() { for locale in self.expectedLocales { let stringsURL = self.resourcesURL .appendingPathComponent("\(locale).lproj") @@ -85,7 +85,7 @@ final class LocalizationTests: XCTestCase { } } - func testTraditionalChineseLocaleExistsAndIsDistinctFromSimplified() throws { + func testTraditionalChineseLocaleExistsAndIsDistinctFromSimplified() { let hantURL = self.resourcesURL .appendingPathComponent("zh-Hant.lproj") .appendingPathComponent("Localizable.strings") @@ -95,8 +95,8 @@ final class LocalizationTests: XCTestCase { guard let hant = NSDictionary(contentsOf: hantURL) as? [String: String], - let hans = NSDictionary(contentsOf: hansURL) as? [String: String] - else { + let hans = NSDictionary(contentsOf: hansURL) as? [String: String] else + { XCTFail("zh-Hant or zh-Hans failed to parse") return } @@ -104,6 +104,10 @@ final class LocalizationTests: XCTestCase { XCTAssertEqual(hant.keys.sorted(), hans.keys.sorted(), "zh-Hant must cover the same keys as zh-Hans") // 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") + XCTAssertNotEqual( + hant["Quit"], + hans["Quit"], + "zh-Hant should use Traditional characters, not the Simplified value" + ) } } diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift index 4c97f78..bd313a2 100644 --- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift +++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift @@ -78,7 +78,7 @@ public final class SleepPreventionManager { /// Number of assertions currently held by the manager. Exposed for tests. public var heldAssertionCount: Int { [self.idleDisplayAssertionID, self.idleSystemAssertionID, self.preventSystemAssertionID] - .compactMap { $0 } + .compactMap(\.self) .count } From 1b6388c38f5a8995b350682c060a6a10d585c051 Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 11:52:58 +0800 Subject: [PATCH 7/8] Address PR #1 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. #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. #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. #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. --- .../LaunchAtLoginManagerTests.swift | 14 ++++ .../SleepPreventionManagerTests.swift | 47 +++++++++++++ scripts/integration-test.sh | 27 ++++++-- .../Classes/Models/LaunchAtLoginManager.swift | 28 ++++++-- .../Models/SleepPreventionManager.swift | 67 +++++++++++-------- 5 files changed, 144 insertions(+), 39 deletions(-) diff --git a/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift index ffb9098..539903b 100644 --- a/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift +++ b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift @@ -54,6 +54,20 @@ final class LaunchAtLoginManagerTests: XCTestCase { XCTAssertFalse(ok) XCTAssertEqual(backend.registerCalls, 1) XCTAssertFalse(manager.isEnabled, "register threw, so backend state stayed false; manager must mirror.") + XCTAssertNotNil(manager.lastError, "register threw; lastError must surface it for the UI to act on.") + } + + func testSuccessClearsPreviousLastError() { + let backend = FakeLaunchItemBackend() + backend.registerError = FakeError.boom + let manager = LaunchAtLoginManager(backend: backend) + _ = manager.setEnabled(true) + XCTAssertNotNil(manager.lastError) + + backend.registerError = nil + _ = manager.setEnabled(true) + + XCTAssertNil(manager.lastError, "A subsequent successful call must clear the prior error.") } func testRefreshReadsBackendState() { diff --git a/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift index 993df59..5547133 100644 --- a/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift +++ b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift @@ -88,26 +88,73 @@ final class SleepPreventionManagerTests: XCTestCase { XCTAssertTrue(fake.createCalls.allSatisfy { $0.timeout == 30 }) } + + func testManagerDeallocatesWhenOutOfScope() { + // A selector-based NSWorkspace observer retains its target, which + // would keep the manager alive forever and leak across tests. With + // Combine + AnyCancellable, dropping the manager must drop the + // observers too, and weakRef must become nil. + weak var weakRef: SleepPreventionManager? + do { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + weakRef = manager + XCTAssertNotNil(weakRef) + } + XCTAssertNil(weakRef, "Manager leaked — NSWorkspace observers are still retaining it.") + } + + func testRefreshCreatesNewAssertionsBeforeReleasingOld() { + // A release-then-create implementation leaves a microsecond gap with + // zero held assertions; the contract is swap-then-release so the + // kernel always sees at least one of each type during a refresh. + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: false) + fake.operationLog.removeAll() + + manager.updateAllowLidClose(true) + + guard let firstRelease = fake.operationLog.firstIndex(where: { $0.kind == .release }) else { + XCTFail("expected at least one release during refresh") + return + } + let createsBeforeFirstRelease = fake.operationLog[.. UInt32? { let id = self.nextID self.nextID += 1 self.liveAssertions[id] = type self.createCalls.append((type, reason, timeout)) + self.operationLog.append(FakeOperation(kind: .create, id: id, type: type)) return id } func release(_ id: UInt32) { + let type = self.liveAssertions[id] ?? "unknown" self.releaseCalls.append(id) self.liveAssertions.removeValue(forKey: id) + self.operationLog.append(FakeOperation(kind: .release, id: id, type: type)) } } diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 1aea0ab..cb4c860 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -37,6 +37,26 @@ cleanup() { } trap cleanup EXIT +# Polls `pmset -g assertions` for up to ASSERTION_POLL_TIMEOUT seconds (default 30) +# until at least one Caffeine-owned assertion appears, or fails. Returns the +# matched output on stdout. +poll_for_caffeine_assertions() { + local timeout="${ASSERTION_POLL_TIMEOUT:-30}" + local interval=1 + local elapsed=0 + local out="" + while ((elapsed < timeout)); do + out=$(pmset -g assertions | grep -E "\(Caffeine\)" || true) + if [[ -n "$out" ]]; then + printf '%s\n' "$out" + return 0 + fi + sleep "$interval" + elapsed=$((elapsed + interval)) + done + return 1 +} + run_case() { local mode="$1" local expect_present="$2" @@ -45,12 +65,10 @@ run_case() { echo "==> Case: CA_TEST_AUTOACTIVATE=$mode" 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" + if ! out=$(poll_for_caffeine_assertions); then + echo "FAIL: no Caffeine-owned assertions appeared within ${ASSERTION_POLL_TIMEOUT:-30}s" pmset -g assertions | tail -50 return 1 fi @@ -76,7 +94,6 @@ run_case() { kill "$APP_PID" 2>/dev/null || true wait "$APP_PID" 2>/dev/null || true APP_PID="" - sleep 1 } run_case "lid-closed" "PreventUserIdleDisplaySleep PreventUserIdleSystemSleep PreventSystemSleep" diff --git a/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift index bd67fd0..8d1535a 100644 --- a/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift +++ b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift @@ -5,6 +5,9 @@ import Foundation import Observation +import os + +private let logger = Logger(subsystem: "net.domzilla.caffeine", category: "LaunchAtLoginManager") /// Source of truth for the "Launch at Login" preference. The published /// `isEnabled` value mirrors the underlying ``LaunchItemBackend``; user-driven @@ -17,6 +20,10 @@ public final class LaunchAtLoginManager { public private(set) var isEnabled: Bool = false + /// The error from the most recent `setEnabled(_:)` call, if any. + /// Cleared on the next successful operation. + public private(set) var lastError: (any Error)? + private let backend: any LaunchItemBackend public init(backend: any LaunchItemBackend = SMAppServiceBackend.shared) { @@ -31,20 +38,31 @@ public final class LaunchAtLoginManager { /// Attempts to enable or disable the login item. /// - Returns: `true` if the backend call succeeded; `false` if it threw. + /// + /// On failure, the error is logged via `os.Logger` (visible in Console.app + /// under subsystem `net.domzilla.caffeine`) and surfaced on ``lastError`` + /// so callers can present it to the user. ``refresh()`` is always called + /// afterwards so the published `isEnabled` reflects the backend's truth — + /// for a SwiftUI `Toggle` this means a failed register snaps the toggle + /// back to off. @discardableResult 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 + self.lastError = nil + self.refresh() + return true } catch { - succeeded = false + logger.error( + "Failed to \(enabled ? "register" : "unregister", privacy: .public) login item: \(error.localizedDescription, privacy: .public)" + ) + self.lastError = error + self.refresh() + return false } - self.refresh() - return succeeded } } diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift index bd313a2..12edeb7 100644 --- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift +++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift @@ -6,6 +6,7 @@ // import AppKit +import Combine import Foundation import IOKit.pwr_mgt @@ -32,6 +33,7 @@ public final class SleepPreventionManager { private var isUserSessionActive = true private var allowLidClose = false private var isActive = false + private var sessionObservers = Set() public init(backend: any PowerAssertionBackend = IOKitPowerAssertionBackend.shared) { self.backend = backend @@ -86,25 +88,40 @@ public final class SleepPreventionManager { private func refreshAssertions() { guard self.isUserSessionActive else { return } - self.releaseAll() let reason = String(localized: "Caffeine prevents sleep") - self.idleDisplayAssertionID = self.backend.create( + + // Swap-then-release: hold both old and new IDs briefly so the kernel + // always sees at least one of each assertion type, even at the exact + // moment of refresh. Releasing first would leave a microsecond gap. + let newIdleDisplay = self.backend.create( type: kIOPMAssertPreventUserIdleDisplaySleep as String, reason: reason, timeout: 30 ) - self.idleSystemAssertionID = self.backend.create( + let newIdleSystem = self.backend.create( type: kIOPMAssertPreventUserIdleSystemSleep as String, reason: reason, timeout: 30 ) - if self.allowLidClose { - self.preventSystemAssertionID = self.backend.create( + let newPreventSystem: UInt32? = self.allowLidClose + ? self.backend.create( type: kIOPMAssertionTypePreventSystemSleep as String, reason: reason, timeout: 30 ) - } + : nil + + let oldIdleDisplay = self.idleDisplayAssertionID + let oldIdleSystem = self.idleSystemAssertionID + let oldPreventSystem = self.preventSystemAssertionID + + self.idleDisplayAssertionID = newIdleDisplay + self.idleSystemAssertionID = newIdleSystem + self.preventSystemAssertionID = newPreventSystem + + if let id = oldIdleDisplay { self.backend.release(id) } + if let id = oldIdleSystem { self.backend.release(id) } + if let id = oldPreventSystem { self.backend.release(id) } } private func releaseAll() { @@ -117,30 +134,22 @@ public final class SleepPreventionManager { } private func setupWorkspaceNotifications() { + // Publisher + AnyCancellable so the NSWorkspace observers are removed + // automatically when the manager deallocates (test instances), matching + // CaffeineViewModel's pattern. A selector-based observer would persist + // forever because NotificationCenter retains its targets. let nc = NSWorkspace.shared.notificationCenter - nc.addObserver( - self, - selector: #selector(self.sessionDidResignActive), - name: NSWorkspace.sessionDidResignActiveNotification, - object: nil - ) - - nc.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 + nc.publisher(for: NSWorkspace.sessionDidResignActiveNotification) + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.isUserSessionActive = false } + } + .store(in: &self.sessionObservers) + + nc.publisher(for: NSWorkspace.sessionDidBecomeActiveNotification) + .sink { [weak self] _ in + Task { @MainActor [weak self] in self?.isUserSessionActive = true } + } + .store(in: &self.sessionObservers) } } From 7f6b451eb37cd2a439691217dbe9d7da24d530ac Mon Sep 17 00:00:00 2001 From: bubble Date: Tue, 19 May 2026 15:21:53 +0800 Subject: [PATCH 8/8] Address PR #1 second-round review feedback #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. #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. #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. #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. #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. --- .../CaffeineCoreTests/LocalizationTests.swift | 18 ++++--- .../SleepPreventionManagerTests.swift | 48 +++++++++++++++++++ .../Models/SleepPreventionManager.swift | 23 ++++++++- .../ViewModels/CaffeineViewModel.swift | 26 ++++++---- .../Classes/Views/MenuBarController.swift | 2 +- .../Resources/de.lproj/Localizable.strings | 2 +- .../Resources/en.lproj/Localizable.strings | 2 +- .../Resources/es.lproj/Localizable.strings | 2 +- .../Resources/fr.lproj/Localizable.strings | 2 +- .../Resources/it.lproj/Localizable.strings | 2 +- .../Resources/ja.lproj/Localizable.strings | 2 +- .../Resources/ko.lproj/Localizable.strings | 2 +- .../Resources/nl.lproj/Localizable.strings | 2 +- .../Resources/pt-BR.lproj/Localizable.strings | 2 +- .../Resources/pt.lproj/Localizable.strings | 2 +- .../Resources/ru.lproj/Localizable.strings | 2 +- .../Resources/uk.lproj/Localizable.strings | 2 +- .../zh-Hans.lproj/Localizable.strings | 2 +- .../zh-Hant.lproj/Localizable.strings | 2 +- 19 files changed, 113 insertions(+), 32 deletions(-) diff --git a/Tests/CaffeineCoreTests/LocalizationTests.swift b/Tests/CaffeineCoreTests/LocalizationTests.swift index a762da0..943a8a7 100644 --- a/Tests/CaffeineCoreTests/LocalizationTests.swift +++ b/Tests/CaffeineCoreTests/LocalizationTests.swift @@ -55,7 +55,7 @@ final class LocalizationTests: XCTestCase { // Menu additions "Activate for", "Welcome to 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© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine", // System messages "Caffeine prevents sleep", ] @@ -102,12 +102,16 @@ final class LocalizationTests: XCTestCase { } XCTAssertEqual(hant.keys.sorted(), hans.keys.sorted(), "zh-Hant must cover the same keys as zh-Hans") - // 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" + + // Beyond key parity, the two scripts must differ for the bulk of the + // user-facing values. A single representative key isn't enough — it + // could happen to translate identically in both scripts. Require that + // at least half the values are non-identical across the two locales. + let differingValues = self.expectedKeys.filter { hant[$0] != hans[$0] } + let ratio = Double(differingValues.count) / Double(self.expectedKeys.count) + XCTAssertGreaterThan( + ratio, 0.5, + "Only \(differingValues.count)/\(self.expectedKeys.count) keys differ between zh-Hant and zh-Hans — one of the locales is probably a copy of the other." ) } } diff --git a/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift index 5547133..2517959 100644 --- a/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift +++ b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift @@ -89,6 +89,54 @@ final class SleepPreventionManagerTests: XCTestCase { XCTAssertTrue(fake.createCalls.allSatisfy { $0.timeout == 30 }) } + func testSessionResignReleasesHeldAssertionsImmediately() { + // Without this behaviour, the kernel times the assertions out 30 s + // later and the manager's stored IDs drift out of sync with reality. + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: true) + XCTAssertEqual(manager.heldAssertionCount, 3) + + manager.handleSessionResignActive() + + XCTAssertEqual( + manager.heldAssertionCount, + 0, + "Resign-active must release immediately, not wait for the kernel timeout." + ) + XCTAssertEqual(fake.releaseCalls.count, 3) + XCTAssertTrue(fake.liveAssertions.isEmpty) + } + + func testSessionBecomeActiveReengagesIfActive() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.preventSleep(allowLidClose: true) + manager.handleSessionResignActive() + XCTAssertEqual(manager.heldAssertionCount, 0) + + manager.handleSessionBecomeActive() + + XCTAssertEqual( + manager.heldAssertionCount, + 3, + "Become-active should re-engage immediately, not wait up to 10 s for the next timer tick." + ) + } + + func testSessionBecomeActiveDoesNothingIfNotActive() { + let fake = FakePowerAssertionBackend() + let manager = SleepPreventionManager(backend: fake) + manager.handleSessionResignActive() + manager.handleSessionBecomeActive() + + XCTAssertEqual( + manager.heldAssertionCount, + 0, + "Session events must not create assertions when the user hasn't activated Caffeine." + ) + } + func testManagerDeallocatesWhenOutOfScope() { // A selector-based NSWorkspace observer retains its target, which // would keep the manager alive forever and leak across tests. With diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift index 12edeb7..13a4068 100644 --- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift +++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift @@ -142,14 +142,33 @@ public final class SleepPreventionManager { nc.publisher(for: NSWorkspace.sessionDidResignActiveNotification) .sink { [weak self] _ in - Task { @MainActor [weak self] in self?.isUserSessionActive = false } + Task { @MainActor [weak self] in self?.handleSessionResignActive() } } .store(in: &self.sessionObservers) nc.publisher(for: NSWorkspace.sessionDidBecomeActiveNotification) .sink { [weak self] _ in - Task { @MainActor [weak self] in self?.isUserSessionActive = true } + Task { @MainActor [weak self] in self?.handleSessionBecomeActive() } } .store(in: &self.sessionObservers) } + + /// Internal (not private) so tests can drive these directly without + /// posting a real NSWorkspace notification and waiting for the Task + /// @MainActor hop. Not part of the public API. + func handleSessionResignActive() { + self.isUserSessionActive = false + // Release immediately so the manager's stored IDs match the kernel's + // view of the world (the kernel will time them out anyway after 30 s). + // Without this, `heldAssertionCount` lies during the inactive window + // and re-engagement on resume waits up to 10 s for the next timer fire. + self.releaseAll() + } + + func handleSessionBecomeActive() { + self.isUserSessionActive = true + // Re-engage immediately on resume rather than waiting up to 10 s for + // the timer's next tick. + if self.isActive { self.refreshAssertions() } + } } diff --git a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift index f27b2be..5347efb 100644 --- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift +++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift @@ -36,10 +36,13 @@ class CaffeineViewModel: ObservableObject { #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. + // lid-close flag. Passes the value as an override so we don't write + // to the persistent UserDefaults from a test env var. Intentional + // early return so we don't pop the preferences window during a + // headless integration run — add future init above this guard, not + // below it. Compiled out in Release. if let mode = ProcessInfo.processInfo.environment["CA_TEST_AUTOACTIVATE"] { - UserDefaults.standard.set(mode == "lid-closed", forKey: PreferenceKeys.allowLidClose) - self.activate() + self.activate(allowLidCloseOverride: mode == "lid-closed") return } #endif @@ -66,8 +69,13 @@ class CaffeineViewModel: ObservableObject { } } - /// Activates Caffeine with optional timeout - func activate(withTimeout timeout: TimeInterval? = nil) { + /// Activates Caffeine with optional timeout. + /// - Parameters: + /// - timeout: optional duration before auto-deactivation. + /// - allowLidCloseOverride: if non-nil, used instead of the stored + /// `allowLidClose` preference. Intended for DEBUG test hooks that + /// want to drive a known state without mutating UserDefaults. + func activate(withTimeout timeout: TimeInterval? = nil, allowLidCloseOverride: Bool? = nil) { // Use default duration if no timeout specified let duration: TimeInterval? if let timeout { @@ -119,7 +127,8 @@ class CaffeineViewModel: ObservableObject { } self.isActive = true - let allowLidClose = UserDefaults.standard.bool(forKey: PreferenceKeys.allowLidClose) + let allowLidClose = allowLidCloseOverride + ?? UserDefaults.standard.bool(forKey: PreferenceKeys.allowLidClose) SleepPreventionManager.shared.preventSleep(allowLidClose: allowLidClose) if UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) { @@ -127,9 +136,10 @@ class CaffeineViewModel: ObservableObject { } } - /// Updates the lid-close flag and applies it live if currently active. + /// Applies the lid-close flag to the active sleep-prevention manager. + /// Persistence is owned by PreferencesView's `@AppStorage` binding, so + /// this VM doesn't write to UserDefaults itself. func setAllowLidClose(_ enabled: Bool) { - UserDefaults.standard.set(enabled, forKey: PreferenceKeys.allowLidClose) SleepPreventionManager.shared.updateAllowLidClose(enabled) } diff --git a/src/Caffeine/Classes/Views/MenuBarController.swift b/src/Caffeine/Classes/Views/MenuBarController.swift index 8747766..3bb545e 100644 --- a/src/Caffeine/Classes/Views/MenuBarController.swift +++ b/src/Caffeine/Classes/Views/MenuBarController.swift @@ -222,7 +222,7 @@ class MenuBarController: NSObject { let credits = String( - localized: "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n\nSource code:\nhttps://github.caffeine-app.net" + localized: "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" ) NSApp.orderFrontStandardAboutPanel(options: [ diff --git a/src/Caffeine/Resources/de.lproj/Localizable.strings b/src/Caffeine/Resources/de.lproj/Localizable.strings index 9ba5faf..8236a0a 100644 --- a/src/Caffeine/Resources/de.lproj/Localizable.strings +++ b/src/Caffeine/Resources/de.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Willkommen bei 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\nQuellcode:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nQuellcode:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine verhindert den Ruhezustand"; diff --git a/src/Caffeine/Resources/en.lproj/Localizable.strings b/src/Caffeine/Resources/en.lproj/Localizable.strings index ee1a275..fd92f8a 100644 --- a/src/Caffeine/Resources/en.lproj/Localizable.strings +++ b/src/Caffeine/Resources/en.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Welcome to 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\nSource code:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine prevents sleep"; diff --git a/src/Caffeine/Resources/es.lproj/Localizable.strings b/src/Caffeine/Resources/es.lproj/Localizable.strings index bda292b..be43ff8 100644 --- a/src/Caffeine/Resources/es.lproj/Localizable.strings +++ b/src/Caffeine/Resources/es.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Bienvenido a 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\nCódigo fuente:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nCódigo fuente:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine impide el reposo"; diff --git a/src/Caffeine/Resources/fr.lproj/Localizable.strings b/src/Caffeine/Resources/fr.lproj/Localizable.strings index f0c05bc..7da8b26 100644 --- a/src/Caffeine/Resources/fr.lproj/Localizable.strings +++ b/src/Caffeine/Resources/fr.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Bienvenue dans 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\nCode source :\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nCode source :\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine empêche la mise en veille"; diff --git a/src/Caffeine/Resources/it.lproj/Localizable.strings b/src/Caffeine/Resources/it.lproj/Localizable.strings index 8643120..a2b4e5a 100644 --- a/src/Caffeine/Resources/it.lproj/Localizable.strings +++ b/src/Caffeine/Resources/it.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Benvenuto in 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\nCodice sorgente:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nCodice sorgente:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine impedisce lo stop"; diff --git a/src/Caffeine/Resources/ja.lproj/Localizable.strings b/src/Caffeine/Resources/ja.lproj/Localizable.strings index bcbe718..d4f2fe1 100644 --- a/src/Caffeine/Resources/ja.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ja.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nソースコード:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine がスリープを防ぎます"; diff --git a/src/Caffeine/Resources/ko.lproj/Localizable.strings b/src/Caffeine/Resources/ko.lproj/Localizable.strings index 3f045b6..a96d302 100644 --- a/src/Caffeine/Resources/ko.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ko.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\n소스 코드:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine이 잠자기를 방지합니다"; diff --git a/src/Caffeine/Resources/nl.lproj/Localizable.strings b/src/Caffeine/Resources/nl.lproj/Localizable.strings index 92521fe..026ff1a 100644 --- a/src/Caffeine/Resources/nl.lproj/Localizable.strings +++ b/src/Caffeine/Resources/nl.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Welkom bij 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\nBroncode:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nBroncode:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine voorkomt sluimerstand"; diff --git a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings index b4afa98..b5f6aca 100644 --- a/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt-BR.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Bem-vindo ao 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\nCódigo-fonte:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nCódigo-fonte:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "O Caffeine impede o repouso"; diff --git a/src/Caffeine/Resources/pt.lproj/Localizable.strings b/src/Caffeine/Resources/pt.lproj/Localizable.strings index 63cb843..fd22c14 100644 --- a/src/Caffeine/Resources/pt.lproj/Localizable.strings +++ b/src/Caffeine/Resources/pt.lproj/Localizable.strings @@ -41,7 +41,7 @@ "Welcome to Caffeine" = "Bem-vindo ao 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\nCódigo-fonte:\nhttps://github.caffeine-app.net"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nCódigo-fonte:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "O Caffeine impede o repouso"; diff --git a/src/Caffeine/Resources/ru.lproj/Localizable.strings b/src/Caffeine/Resources/ru.lproj/Localizable.strings index 6518e44..43456c6 100644 --- a/src/Caffeine/Resources/ru.lproj/Localizable.strings +++ b/src/Caffeine/Resources/ru.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nИсходный код:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine предотвращает переход в сон"; diff --git a/src/Caffeine/Resources/uk.lproj/Localizable.strings b/src/Caffeine/Resources/uk.lproj/Localizable.strings index 791beda..509229c 100644 --- a/src/Caffeine/Resources/uk.lproj/Localizable.strings +++ b/src/Caffeine/Resources/uk.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nВихідний код:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine запобігає переходу в режим сну"; diff --git a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings index fd90329..1c5ef2f 100644 --- a/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings +++ b/src/Caffeine/Resources/zh-Hans.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\n源代码:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine 会阻止睡眠"; diff --git a/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings b/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings index 17c5d77..cf858a3 100644 --- a/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings +++ b/src/Caffeine/Resources/zh-Hant.lproj/Localizable.strings @@ -41,7 +41,7 @@ "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"; +"© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine" = "© 2006 Tomas Franzén\n© 2018 Michael Jones\n© 2022 Dominic Rodemer\n© 2026 @bubbleee030\n\n原始碼:\nhttps://github.com/bubbleee030/Caffeine"; /* System messages */ "Caffeine prevents sleep" = "Caffeine 防止睡眠";