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/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/Package.swift b/Package.swift
new file mode 100644
index 0000000..ec4b575
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,37 @@
+// 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: [
+ "LaunchAtLoginManager.swift",
+ "LaunchItemBackend.swift",
+ "PowerAssertionBackend.swift",
+ "SleepPreventionManager.swift",
+ ]
+ ),
+ .testTarget(
+ name: "CaffeineCoreTests",
+ dependencies: ["CaffeineCore"],
+ path: "Tests/CaffeineCoreTests"
+ ),
+ ]
+)
diff --git a/README.md b/README.md
index e48715d..062ee68 100755
--- a/README.md
+++ b/README.md
@@ -1,46 +1,110 @@
-
+
# 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
-
+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
-
+| 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
-
+### 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
+
+### 讓你的 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)。
diff --git a/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift
new file mode 100644
index 0000000..539903b
--- /dev/null
+++ b/Tests/CaffeineCoreTests/LaunchAtLoginManagerTests.swift
@@ -0,0 +1,108 @@
+//
+// 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.")
+ 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() {
+ 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/Tests/CaffeineCoreTests/LocalizationTests.swift b/Tests/CaffeineCoreTests/LocalizationTests.swift
new file mode 100644
index 0000000..943a8a7
--- /dev/null
+++ b/Tests/CaffeineCoreTests/LocalizationTests.swift
@@ -0,0 +1,117 @@
+//
+// 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© 2026 @bubbleee030\n\nSource code:\nhttps://github.com/bubbleee030/Caffeine",
+ // System messages
+ "Caffeine prevents sleep",
+ ]
+
+ func testEveryLocaleParsesAndContainsAllKeys() {
+ 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() {
+ 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")
+
+ // 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/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/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift
new file mode 100644
index 0000000..2517959
--- /dev/null
+++ b/Tests/CaffeineCoreTests/SleepPreventionManagerTests.swift
@@ -0,0 +1,208 @@
+//
+// 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 })
+ }
+
+ 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
+ // 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
new file mode 100755
index 0000000..cb4c860
--- /dev/null
+++ b/scripts/integration-test.sh
@@ -0,0 +1,102 @@
+#!/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
+
+# 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"
+ local expect_absent="${3:-}"
+
+ echo "==> Case: CA_TEST_AUTOACTIVATE=$mode"
+ CA_TEST_AUTOACTIVATE="$mode" "$BINARY" &
+ APP_PID=$!
+
+ local out
+ 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
+
+ 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=""
+}
+
+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/LaunchAtLoginManager.swift b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift
new file mode 100644
index 0000000..8d1535a
--- /dev/null
+++ b/src/Caffeine/Classes/Models/LaunchAtLoginManager.swift
@@ -0,0 +1,68 @@
+//
+// LaunchAtLoginManager.swift
+// Caffeine
+//
+
+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
+/// 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
+
+ /// 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) {
+ 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.
+ ///
+ /// 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 {
+ do {
+ if enabled, !self.backend.isEnabled {
+ try self.backend.register()
+ } else if !enabled, self.backend.isEnabled {
+ try self.backend.unregister()
+ }
+ self.lastError = nil
+ self.refresh()
+ return true
+ } catch {
+ logger.error(
+ "Failed to \(enabled ? "register" : "unregister", privacy: .public) login item: \(error.localizedDescription, privacy: .public)"
+ )
+ self.lastError = error
+ self.refresh()
+ return false
+ }
+ }
+}
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)
+ }
+}
diff --git a/src/Caffeine/Classes/Models/SleepPreventionManager.swift b/src/Caffeine/Classes/Models/SleepPreventionManager.swift
index 5c6a692..13a4068 100644
--- a/src/Caffeine/Classes/Models/SleepPreventionManager.swift
+++ b/src/Caffeine/Classes/Models/SleepPreventionManager.swift
@@ -6,110 +6,169 @@
//
import AppKit
+import Combine
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 var sessionObservers = Set()
- 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()
+ }
+
+ /// 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
- func allowSleep() {
+ /// 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(\.self)
+ .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 }
+ let reason = String(localized: "Caffeine prevents sleep")
+
+ // 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
)
-
- if result == kIOReturnSuccess {
- self.sleepAssertionID = assertionID
- }
+ let newIdleSystem = self.backend.create(
+ type: kIOPMAssertPreventUserIdleSystemSleep as String,
+ reason: reason,
+ timeout: 30
+ )
+ 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 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
-
- notificationCenter.addObserver(
- self,
- selector: #selector(self.sessionDidResignActive),
- name: NSWorkspace.sessionDidResignActiveNotification,
- object: nil
- )
-
- notificationCenter.addObserver(
- self,
- selector: #selector(self.sessionDidBecomeActive),
- name: NSWorkspace.sessionDidBecomeActiveNotification,
- object: nil
- )
+ // 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.publisher(for: NSWorkspace.sessionDidResignActiveNotification)
+ .sink { [weak self] _ in
+ 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?.handleSessionBecomeActive() }
+ }
+ .store(in: &self.sessionObservers)
}
- @objc
- private func sessionDidResignActive() {
+ /// 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()
}
- @objc
- private func sessionDidBecomeActive() {
+ 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 407c4d1..5347efb 100644
--- a/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
+++ b/src/Caffeine/Classes/ViewModels/CaffeineViewModel.swift
@@ -33,6 +33,20 @@ 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. 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"] {
+ self.activate(allowLidCloseOverride: mode == "lid-closed")
+ return
+ }
+ #endif
+
// Check if we should activate at launch
if UserDefaults.standard.bool(forKey: PreferenceKeys.activateAtLaunch) {
self.activate()
@@ -55,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 {
@@ -108,13 +127,22 @@ class CaffeineViewModel: ObservableObject {
}
self.isActive = true
- SleepPreventionManager.shared.preventSleep()
+ let allowLidClose = allowLidCloseOverride
+ ?? UserDefaults.standard.bool(forKey: PreferenceKeys.allowLidClose)
+ SleepPreventionManager.shared.preventSleep(allowLidClose: allowLidClose)
if UserDefaults.standard.bool(forKey: PreferenceKeys.keepAppsActive) {
ActivitySimulator.shared.startMonitoring()
}
}
+ /// 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) {
+ SleepPreventionManager.shared.updateAllowLidClose(enabled)
+ }
+
/// Deactivates Caffeine
func deactivate() {
self.cancelTimers()
@@ -212,4 +240,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/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/Classes/Views/PreferencesView.swift b/src/Caffeine/Classes/Views/PreferencesView.swift
index 9b9c2d1..f9d0cf4 100644
--- a/src/Caffeine/Classes/Views/PreferencesView.swift
+++ b/src/Caffeine/Classes/Views/PreferencesView.swift
@@ -9,11 +9,13 @@ 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
@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) {
@@ -67,6 +69,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))
@@ -79,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)
@@ -121,6 +143,7 @@ struct PreferencesView: View {
.padding(.horizontal, 20)
.frame(width: 640)
.fixedSize(horizontal: false, vertical: true)
+ .onAppear { self.loginManager.refresh() }
}
}
diff --git a/src/Caffeine/Resources/de.lproj/Localizable.strings b/src/Caffeine/Resources/de.lproj/Localizable.strings
index fddf93f..8236a0a 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 */
@@ -38,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 66e5e4c..fd92f8a 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 */
@@ -38,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 e708b41..be43ff8 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 */
@@ -38,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 e1388d1..7da8b26 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 */
@@ -38,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 69891b9..a2b4e5a 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 */
@@ -38,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 c020ed0..d4f2fe1 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 */
@@ -38,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 f05d37e..a96d302 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 */
@@ -38,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 ce3caa7..026ff1a 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 */
@@ -38,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 f9764e8..b5f6aca 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 */
@@ -38,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 53c6852..fd22c14 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 */
@@ -38,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 a83a207..43456c6 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 */
@@ -38,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 b1cd894..509229c 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 */
@@ -38,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 768aca3..1c5ef2f 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 */
@@ -38,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
new file mode 100644
index 0000000..cf858a3
--- /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© 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 防止睡眠";