commit 2363fc21650b4460704245ec71542c48c52b81d3 Author: Gary Hansen Date: Tue Sep 15 12:44:18 2026 +1000 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d335fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.build/ +dist/ +.swiftpm/ +*.xcodeproj +*.xcworkspace +.DS_Store +Package.resolved diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..a5115b4 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "configurations": [ + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:Wakeful}", + "name": "Debug Wakeful", + "target": "Wakeful", + "configuration": "debug", + "preLaunchTask": "swift: Build Debug Wakeful" + }, + { + "type": "swift", + "request": "launch", + "args": [], + "cwd": "${workspaceFolder:Wakeful}", + "name": "Release Wakeful", + "target": "Wakeful", + "configuration": "release", + "preLaunchTask": "swift: Build Release Wakeful" + } + ] +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fcc7bd8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gary Hansen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7481330 --- /dev/null +++ b/Makefile @@ -0,0 +1,78 @@ +APP_NAME := Wakeful +BUNDLE_ID := com.github.wakeful.Wakeful +VERSION := 1.0.0 + +# Override with: make SIGN_ID="Developer ID Application: Your Name (TEAMID)" +SIGN_ID ?= - + +# ?= so `bundle` can be re-invoked with a different source directory. +BUILD_DIR ?= .build/release +UNIVERSAL_DIR := .build/apple/Products/Release +DIST := dist +APP := $(DIST)/$(APP_NAME).app +CONTENTS := $(APP)/Contents + +# A secure timestamp is required for notarization but impossible for ad-hoc. +TS := $(if $(filter -,$(SIGN_ID)),--timestamp=none,--timestamp) + +.PHONY: all build universal app app-universal bundle sign install uninstall run clean + +all: app + +## Build for this Mac's architecture only (fast). +build: + swift build -c release + +## Build a universal (arm64 + x86_64) binary. Note SwiftPM writes multi-arch +## builds to a different directory, which is why `bundle` is parameterised. +universal: + swift build -c release --arch arm64 --arch x86_64 + +## Assemble and sign Wakeful.app into ./dist +app: build bundle + +## Same, from a universal binary. +app-universal: universal + $(MAKE) bundle BUILD_DIR=$(UNIVERSAL_DIR) + +bundle: + rm -rf "$(APP)" + mkdir -p "$(CONTENTS)/MacOS" "$(CONTENTS)/Resources" + cp "$(BUILD_DIR)/$(APP_NAME)" "$(CONTENTS)/MacOS/$(APP_NAME)" + sed -e 's|__BUNDLE_ID__|$(BUNDLE_ID)|g' \ + -e 's|__VERSION__|$(VERSION)|g' \ + Resources/Info.plist > "$(CONTENTS)/Info.plist" + printf 'APPL????' > "$(CONTENTS)/PkgInfo" + $(MAKE) sign + @echo "" + @echo "Built $(APP)" + @echo "Run 'make install' to copy it to /Applications and launch it." + +## Code-sign the bundle. Ad-hoc by default; pass SIGN_ID for a real identity. +sign: + codesign --force --options runtime $(TS) --sign "$(SIGN_ID)" "$(APP)" + codesign --verify --verbose=2 "$(APP)" + +## Install to /Applications and launch. Login-item registration needs the app +## to live in a stable location, so install before enabling Launch at Login. +install: app + - osascript -e 'quit app "$(APP_NAME)"' 2>/dev/null || true + rm -rf "/Applications/$(APP_NAME).app" + cp -R "$(APP)" /Applications/ + open "/Applications/$(APP_NAME).app" + +uninstall: + - osascript -e 'quit app "$(APP_NAME)"' 2>/dev/null || true + rm -rf "/Applications/$(APP_NAME).app" + defaults delete $(BUNDLE_ID) 2>/dev/null || true + @echo "Removed. If Launch at Login was on, also check" + @echo "System Settings > General > Login Items & Extensions." + +## Run the built binary straight from the terminal (for debugging). +## Note: Preferences and Launch at Login only behave correctly in the .app bundle. +run: build + "$(BUILD_DIR)/$(APP_NAME)" + +clean: + swift package clean + rm -rf .build "$(DIST)" diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..ca90664 --- /dev/null +++ b/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.9 +// +// Deliberately pinned to tools 5.9 / Swift 5 language mode. Under Swift 6's +// strict concurrency checking, the static mutable state in HotKey's hot key +// registry and Preferences.shared become hard errors, and the @convention(c) +// Carbon event handler's access to that registry becomes an actor-isolation +// error. Migrating means marking HotKey @MainActor plus nonisolated(unsafe) +// on the registry, or moving it behind a lock. +import PackageDescription + +let package = Package( + name: "Wakeful", + platforms: [ + .macOS(.v13) + ], + targets: [ + .executableTarget( + name: "Wakeful", + path: "Sources/Wakeful" + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..fafe065 --- /dev/null +++ b/README.md @@ -0,0 +1,213 @@ +# Wakeful + +A menu bar switch for macOS sleep. Does what `caffeinate -dis` does, without a +terminal window pinned open for the rest of the day. + +Lives in the menu bar, toggles with a global shortcut, starts at login, and can +turn itself on when you plug in or connect an external display. + +--- + +## Why this exists + +`caffeinate` is the right tool with the wrong interface. It's a foreground +process, so it needs a terminal window that you can't close, that survives +nothing, and that tells you nothing about its own state. Wakeful holds *exactly +the same kernel assertions* — it just does it from a background app with a +switch. + +Verify that for yourself at any time: + +```sh +pmset -g assertions +``` + +You'll see `PreventUserIdleDisplaySleep`, `PreventUserIdleSystemSleep`, and +`PreventSystemSleep` attributed to Wakeful, with the reason string +"Wakeful is keeping this Mac awake". + +--- + +## Design + +### Approach + +Rather than shelling out to `/usr/bin/caffeinate` and babysitting a child +process, Wakeful calls `IOPMAssertionCreateWithName` directly — which is all +`caffeinate` itself does. This means: + +- No subprocess, no orphaned `caffeinate` in Activity Monitor if the app dies. +- The kernel releases every assertion automatically on process exit, so a crash + can never leave your Mac permanently awake. +- Assertions are named and attributed properly in `pmset -g assertions`. + +### Assertion → `caffeinate` mapping + +| Menu item | IOKit assertion | `caffeinate` | +|------------------------------------|---------------------------------|--------------| +| Display sleep | `PreventUserIdleDisplaySleep` | `-d` | +| System idle sleep | `PreventUserIdleSystemSleep` | `-i` | +| Full system sleep (AC only) | `PreventSystemSleep` | `-s` | +| Disk spin-down | `PreventDiskIdle` | `-m` | + +Default is `-d -i -s`, i.e. `caffeinate -dis`. The Prevent submenu +shows the equivalent command for whatever you've selected. + +### Module layout + +``` +Sources/Wakeful/ + main.swift NSApplication bootstrap, .accessory activation policy + AppDelegate.swift NSStatusItem + the whole menu, rebuilt on every open + WakeController.swift State machine: on/off, source, expiry, smart rules + SleepBlocker.swift Owns live IOPMAssertionIDs; declarative apply(Set) + PowerMonitor.swift IOPS notifications (AC / charge) + screen-change notifications + Preferences.swift UserDefaults, all keys settable via `defaults write` + HotKey.swift Carbon RegisterEventHotKey global shortcut + LaunchAtLogin.swift SMAppService.mainApp registration +``` + +Two design decisions worth calling out: + +**The menu is rebuilt from scratch in `menuNeedsUpdate(_:)`.** Menu bar apps +accumulate bugs from trying to keep dozens of checkmarks in sync with model +state. Throwing the menu away and regenerating it from `Preferences` and +`WakeController` on each open makes desync structurally impossible, and a menu +opens rarely enough that the cost is irrelevant. + +**`SleepBlocker.apply(_:)` is declarative, not imperative.** You hand it the set +of assertions you want held and it reconciles the delta. No `if held { release }` +branching at call sites, and repeated calls are free. + +### Smart activation semantics + +The tricky part of conditional activation is not the conditions, it's not +fighting the user. Wakeful tracks *why* it is on (`.manual` vs `.automatic`): + +- A rule becoming true turns Wakeful on with source `.automatic`. +- A rule becoming false turns it off again **only if** source is `.automatic`. + Your manual session is never cancelled by a rule. +- Manually turning it **off** while a rule wants it on sets a suppression flag, + so the app doesn't immediately re-enable itself. The flag clears as soon as + the rule stops being satisfied — so unplugging and replugging re-arms it. + +### Global shortcut + +Uses the Carbon `RegisterEventHotKey` API, deliberately **not** `CGEventTap` or +`NSEvent.addGlobalMonitorForEvents`. The latter two would drag the user through +an Accessibility / Input Monitoring permission prompt; `RegisterEventHotKey` +needs no permission and no entitlement at all. + +v1 ships three presets (`⌃⌥⌘A`, `⌃⌥⌘K`, `⇧⌥⌘Z`) rather than a recorder UI, and +none is enabled by default — an app shouldn't claim a system-wide key +combination without being asked. + +--- + +## Build + +Requires Xcode or the Command Line Tools (`xcode-select --install`). + +```sh +git clone wakeful +cd wakeful +make install +``` + +`make install` builds in release mode, assembles `Wakeful.app`, ad-hoc +code-signs it, copies it to `/Applications`, and launches it. + +Other targets: + +| Command | Effect | +|------------------|--------| +| `make app` | Build and sign into `./dist/Wakeful.app` without installing | +| `make app-universal` | Same, from a universal arm64 + x86_64 binary | +| `make run` | Run the raw binary in the terminal (for debugging) | +| `make uninstall` | Quit, remove from `/Applications`, delete preferences | +| `make clean` | Remove build artifacts | + +To sign with a real Developer ID instead of ad-hoc: + +```sh +make install SIGN_ID="Developer ID Application: Your Name (TEAMID)" +``` + +You can also just `open Package.swift` to work on it in Xcode, but note that +Preferences and Launch at Login only behave correctly from the assembled bundle. + +--- + +## Usage + +Click the cup icon in the menu bar. + +- **Turn On / Turn Off** — indefinite session. Same as the global shortcut. +- **Keep Awake For** — 15m / 30m / 1h / 2h / 5h timed session, with a live + countdown in the menu bar. Add 30 minutes to extend one in flight. +- **Prevent** — pick which assertions to hold. At least one is always held. +- **Smart Activation** — auto-enable while on AC, while an external display is + connected, or while on battery above 20/50/80% (the battery rule only applies + *off* AC, otherwise a charged laptop would satisfy it forever). The submenu + footer shows current conditions so you can see why a rule fired. +- **Global Shortcut** — off by default; pick a preset to enable it. If the + combination is already claimed by another app or a System Settings shortcut, + the menu says "unavailable" rather than silently doing nothing. Note that no + global shortcut fires while macOS Secure Input is active — i.e. while a + password field has focus. +- **Launch at Login** — install to `/Applications` *first*; the registration + records the bundle path, so moving the app afterwards breaks it. +- **Turn On at Launch** — combine with Launch at Login for always-awake. + +### Hidden preference + +The toggle and the global shortcut start an indefinite session by default. To +make them start a timed one instead: + +```sh +defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60 +``` + +--- + +## Caveats + +- **Ad-hoc signing.** `make app` signs with `-`, which is fine on the machine + that built it. Anyone downloading a prebuilt binary would need a notarized + build or a right-click → Open. Building from source sidesteps this entirely, + which is why there are no releases. +- **Power assertions cannot stop lid-close sleep.** This is the one thing people + expect and don't get. Assertions never override *user-initiated* sleep, so + `PreventSystemSleep` keeps the Mac out of full sleep while idle on AC but does + nothing when you shut the lid. Clamshell sleep can only be defeated with + `sudo pmset disablesleep 1`, which has no public API and is deliberately out + of scope here. +- **`PreventSystemSleep` is AC-only.** macOS ignores it on battery entirely. +- **Login item approval.** macOS may put the registration in a + `requiresApproval` state the first time; the menu says so, and it's cleared in + System Settings ▸ General ▸ Login Items & Extensions. `SMAppService` requires + a code-signed bundle; ad-hoc signing is expected to satisfy that locally, but + if Launch at Login errors out on your machine, a real Developer ID is the fix. +- **Smart activation is coarse.** "On AC" doesn't distinguish a charger from a + dock. If you want "awake while on this specific display", that's a v1.1 + problem. + +--- + +## Roadmap + +Deliberately out of scope for v1, in rough priority order: + +1. Hotkey recorder UI instead of presets. +2. Per-app rules — stay awake while Zoom / a specific process is running. This + is what people actually reach for after timed sessions. +3. A real Settings window (`NSWindow`), once the menu stops being enough. +4. Optional notification when a timed session expires. +5. Battery-drain guard: auto-release below a floor even during a manual session. +6. App icon + notarized release artifacts, if this is ever shared widely. + +--- + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..74d3d19 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Wakeful + CFBundleIdentifier + __BUNDLE_ID__ + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Wakeful + CFBundleDisplayName + Wakeful + CFBundlePackageType + APPL + CFBundleShortVersionString + __VERSION__ + CFBundleVersion + __VERSION__ + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHumanReadableCopyright + MIT License + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/Sources/Wakeful/AppDelegate.swift b/Sources/Wakeful/AppDelegate.swift new file mode 100644 index 0000000..83530e6 --- /dev/null +++ b/Sources/Wakeful/AppDelegate.swift @@ -0,0 +1,368 @@ +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { + + private let controller = WakeController() + private let power = PowerMonitor() + private let hotKey = HotKey() + private let prefs = Preferences.shared + + private var statusItem: NSStatusItem! + private let menu = NSMenu() + + /// Held so the countdown in the status line stays live while the menu is open. + private weak var statusLineItem: NSMenuItem? + + /// Durations offered in the "Keep awake for" submenu, in minutes. + private let durations: [(String, Int)] = [ + ("15 minutes", 15), + ("30 minutes", 30), + ("1 hour", 60), + ("2 hours", 120), + ("5 hours", 300), + ("Until turned off", 0) + ] + + private let batteryThresholds = [20, 50, 80] + + // MARK: - Lifecycle + + func applicationDidFinishLaunching(_ notification: Notification) { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.button?.toolTip = "Wakeful" + + menu.delegate = self + menu.autoenablesItems = false + statusItem.menu = menu + + controller.attach(power: power) + controller.onChange = { [weak self] in self?.refreshStatusItem() } + + power.onChange = { [weak self] in self?.controller.evaluateAutomatic() } + power.start() + + applyHotKeyPreference() + + if prefs.activateOnLaunch { + controller.turnOn(minutes: prefs.defaultDurationMinutes, source: .manual) + } + controller.evaluateAutomatic() + refreshStatusItem() + } + + func applicationWillTerminate(_ notification: Notification) { + controller.turnOff(source: .manual) + hotKey.unregister() + power.stop() + } + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + true + } + + // MARK: - Status item + + private func refreshStatusItem() { + guard let button = statusItem.button else { return } + + let symbol = controller.isOn ? "cup.and.saucer.fill" : "cup.and.saucer" + let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Wakeful") + image?.isTemplate = true + button.image = image + + if prefs.showCountdown, let remaining = controller.remaining { + button.title = " " + WakeController.format(remaining) + button.imagePosition = .imageLeading + } else { + button.title = "" + button.imagePosition = .imageOnly + } + + button.toolTip = "Wakeful — " + controller.statusText + + // Keep an already-open menu's status line ticking too. + statusLineItem?.title = controller.statusText + } + + // MARK: - Menu construction + + func menuNeedsUpdate(_ menu: NSMenu) { + rebuild(menu) + } + + private func rebuild(_ menu: NSMenu) { + menu.removeAllItems() + + // Status line + let status = NSMenuItem(title: controller.statusText, action: nil, keyEquivalent: "") + status.isEnabled = false + menu.addItem(status) + statusLineItem = status + + if let error = controller.lastError { + let item = NSMenuItem(title: "⚠︎ " + error, action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } + + menu.addItem(.separator()) + + // Primary toggle + let toggle = item( + controller.isOn ? "Turn Off" : "Turn On", + #selector(toggleNow(_:)) + ) + // Only advertise the shortcut if it actually registered. + if prefs.hotKeyIndex >= 0, prefs.hotKeyIndex < HotKeyPreset.all.count, + hotKey.isRegistered { + let preset = HotKeyPreset.all[prefs.hotKeyIndex] + toggle.title += " (\(preset.label))" + } + menu.addItem(toggle) + + // Timed sessions + let durationMenu = NSMenu() + durationMenu.autoenablesItems = false + for (label, minutes) in durations { + let entry = item(label, #selector(startSession(_:)), tag: minutes) + entry.state = (minutes == prefs.defaultDurationMinutes) ? .on : .off + durationMenu.addItem(entry) + } + if controller.remaining != nil { + durationMenu.addItem(.separator()) + durationMenu.addItem(item("Add 30 minutes", #selector(extendSession(_:)), tag: 30)) + } + menu.addItem(submenu("Keep Awake For", durationMenu)) + + menu.addItem(.separator()) + + // Assertion selection + let preventMenu = NSMenu() + preventMenu.autoenablesItems = false + for kind in AssertionKind.displayOrder { + let entry = item(kind.title, #selector(toggleAssertion(_:))) + entry.representedObject = kind.rawValue + // While a session is live, show what is *actually* held, so a failed + // assertion doesn't sit there with a reassuring checkmark. + let checked = controller.isOn ? controller.heldKinds.contains(kind) + : prefs.assertions.contains(kind) + entry.state = checked ? .on : .off + preventMenu.addItem(entry) + } + preventMenu.addItem(.separator()) + menu.addItem(submenu("Prevent", preventMenu)) + + // Smart activation + let smartMenu = NSMenu() + smartMenu.autoenablesItems = false + + let acItem = item("While plugged in to AC", #selector(toggleSmartAC(_:))) + acItem.state = prefs.smartOnAC ? .on : .off + smartMenu.addItem(acItem) + + let displayItem = item("While an external display is connected", + #selector(toggleSmartDisplay(_:))) + displayItem.state = prefs.smartOnExternalDisplay ? .on : .off + smartMenu.addItem(displayItem) + + let batteryMenu = NSMenu() + batteryMenu.autoenablesItems = false + let offItem = item("Off", #selector(setBatteryThreshold(_:)), tag: 0) + offItem.state = prefs.smartBatteryThreshold == 0 ? .on : .off + batteryMenu.addItem(offItem) + for threshold in batteryThresholds { + let entry = item("Above \(threshold)%", #selector(setBatteryThreshold(_:)), + tag: threshold) + entry.state = prefs.smartBatteryThreshold == threshold ? .on : .off + entry.isEnabled = power.hasBattery + batteryMenu.addItem(entry) + } + smartMenu.addItem(submenu("While battery is…", batteryMenu)) + + smartMenu.addItem(.separator()) + let conditions: String + if power.hasBattery { + let charge = power.batteryPercent.map { "\($0)%" } ?? "unknown" + conditions = "Now: \(power.isOnAC ? "AC" : "battery"), \(charge), " + + "\(NSScreen.screens.count) display(s)" + } else { + conditions = "Now: AC (no battery), \(NSScreen.screens.count) display(s)" + } + let conditionsItem = NSMenuItem(title: conditions, action: nil, keyEquivalent: "") + conditionsItem.isEnabled = false + smartMenu.addItem(conditionsItem) + + menu.addItem(submenu("Smart Activation", smartMenu)) + + menu.addItem(.separator()) + + // Global shortcut + let shortcutMenu = NSMenu() + shortcutMenu.autoenablesItems = false + let noneItem = item("Off", #selector(setHotKey(_:)), tag: -1) + noneItem.state = prefs.hotKeyIndex < 0 ? .on : .off + shortcutMenu.addItem(noneItem) + for (index, preset) in HotKeyPreset.all.enumerated() { + let selected = prefs.hotKeyIndex == index + let entry = item(preset.label, #selector(setHotKey(_:)), tag: index) + entry.state = selected ? .on : .off + if selected, !hotKey.isRegistered { + entry.title += " — unavailable (already in use)" + } + shortcutMenu.addItem(entry) + } + menu.addItem(submenu("Global Shortcut", shortcutMenu)) + + // Startup and display options + let launchState = LaunchAtLogin.state + let launchItem = item("Launch at Login", #selector(toggleLaunchAtLogin(_:))) + launchItem.state = (launchState == .enabled || launchState == .requiresApproval) + ? .on : .off + launchItem.isEnabled = LaunchAtLogin.isRunningFromBundle + if launchState == .requiresApproval { + launchItem.title = "Launch at Login (needs approval in System Settings)" + } + menu.addItem(launchItem) + + let onLaunchItem = item("Turn On at Launch", #selector(toggleActivateOnLaunch(_:))) + onLaunchItem.state = prefs.activateOnLaunch ? .on : .off + menu.addItem(onLaunchItem) + + let countdownItem = item("Show Countdown in Menu Bar", #selector(toggleCountdown(_:))) + countdownItem.state = prefs.showCountdown ? .on : .off + menu.addItem(countdownItem) + + menu.addItem(.separator()) + menu.addItem(item("About Wakeful", #selector(showAbout(_:)))) + menu.addItem(item("Quit Wakeful", #selector(quit(_:)), keyEquivalent: "q")) + } + + private func item(_ title: String, + _ action: Selector, + tag: Int = 0, + keyEquivalent: String = "") -> NSMenuItem { + let entry = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) + entry.target = self + entry.tag = tag + entry.isEnabled = true + return entry + } + + private func submenu(_ title: String, _ child: NSMenu) -> NSMenuItem { + let entry = NSMenuItem(title: title, action: nil, keyEquivalent: "") + entry.submenu = child + entry.isEnabled = true + return entry + } + + // MARK: - Actions + + @objc private func toggleNow(_ sender: NSMenuItem) { + controller.toggle() + } + + @objc private func startSession(_ sender: NSMenuItem) { + controller.turnOn(minutes: sender.tag, source: .manual) + } + + @objc private func extendSession(_ sender: NSMenuItem) { + controller.extend(by: sender.tag) + } + + @objc private func toggleAssertion(_ sender: NSMenuItem) { + guard let raw = sender.representedObject as? String, + let kind = AssertionKind(rawValue: raw) else { return } + var current = prefs.assertions + if current.contains(kind) { + // Never let the user disable every assertion — that would leave the + // app switched "on" while doing nothing at all. + guard current.count > 1 else { return } + current.remove(kind) + } else { + current.insert(kind) + } + prefs.assertions = current + controller.reapplyAssertions() + } + + @objc private func toggleSmartAC(_ sender: NSMenuItem) { + prefs.smartOnAC.toggle() + controller.smartRulesChanged() + } + + @objc private func toggleSmartDisplay(_ sender: NSMenuItem) { + prefs.smartOnExternalDisplay.toggle() + controller.smartRulesChanged() + } + + @objc private func setBatteryThreshold(_ sender: NSMenuItem) { + prefs.smartBatteryThreshold = sender.tag + controller.smartRulesChanged() + } + + @objc private func setHotKey(_ sender: NSMenuItem) { + prefs.hotKeyIndex = sender.tag + applyHotKeyPreference() + refreshStatusItem() + } + + @objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) { + let turningOn = (sender.state != .on) + if let message = LaunchAtLogin.set(turningOn) { + presentAlert(title: "Launch at Login", message: message) + } + } + + @objc private func toggleActivateOnLaunch(_ sender: NSMenuItem) { + prefs.activateOnLaunch.toggle() + } + + @objc private func toggleCountdown(_ sender: NSMenuItem) { + prefs.showCountdown.toggle() + refreshStatusItem() + } + + @objc private func showAbout(_ sender: NSMenuItem) { + let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") + as? String ?? "dev" + presentAlert( + title: "Wakeful \(version)", + message: """ + A menu bar switch for macOS sleep. + + Verify what is held at any time with: + pmset -g assertions + """ + ) + } + + @objc private func quit(_ sender: NSMenuItem) { + NSApp.terminate(nil) + } + + // MARK: - Helpers + + private func applyHotKeyPreference() { + hotKey.unregister() + let index = prefs.hotKeyIndex + guard index >= 0, index < HotKeyPreset.all.count else { return } + let preset = HotKeyPreset.all[index] + hotKey.register(keyCode: preset.keyCode, modifiers: preset.modifiers) { [weak self] in + self?.controller.toggle() + } + } + + private func presentAlert(title: String, message: String) { + if #available(macOS 14.0, *) { + NSApp.activate() + } else { + NSApp.activate(ignoringOtherApps: true) + } + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.alertStyle = .informational + alert.addButton(withTitle: "OK") + alert.runModal() + } +} diff --git a/Sources/Wakeful/HotKey.swift b/Sources/Wakeful/HotKey.swift new file mode 100644 index 0000000..858bf82 --- /dev/null +++ b/Sources/Wakeful/HotKey.swift @@ -0,0 +1,130 @@ +import AppKit +import Carbon.HIToolbox + +/// A selectable global shortcut. +/// +/// v1 ships presets rather than a recorder UI — a recorder is a lot of surface +/// area for a toggle that most people set once. +struct HotKeyPreset { + let label: String + let keyCode: UInt32 + let modifiers: UInt32 + + static let all: [HotKeyPreset] = [ + HotKeyPreset(label: "⌃⌥⌘A", keyCode: UInt32(kVK_ANSI_A), + modifiers: UInt32(controlKey | optionKey | cmdKey)), + HotKeyPreset(label: "⌃⌥⌘K", keyCode: UInt32(kVK_ANSI_K), + modifiers: UInt32(controlKey | optionKey | cmdKey)), + HotKeyPreset(label: "⇧⌥⌘Z", keyCode: UInt32(kVK_ANSI_Z), + modifiers: UInt32(shiftKey | optionKey | cmdKey)) + ] +} + +/// Registers a system-wide shortcut using the Carbon hot key API. +/// +/// This deliberately does *not* use `CGEventTap` or +/// `NSEvent.addGlobalMonitorForEvents`, both of which would force the user +/// through an Accessibility / Input Monitoring permission prompt. +/// `RegisterEventHotKey` needs no entitlement and no permission at all. +/// +/// Two limits are inherent to the API: registration fails with +/// `eventHotKeyExistsErr` if another app or a System Settings shortcut already +/// owns the combination, and no hot key fires while macOS Secure Input is +/// active (i.e. while a password field has focus). +final class HotKey { + + /// Keyed by hot key id. Stores the closure rather than the `HotKey` object so + /// that instances are not kept alive by the registry and `deinit` can run. + private static var handlers: [UInt32: () -> Void] = [:] + private static var nextID: UInt32 = 1 + private static var handlerInstalled = false + + private let identifier: UInt32 + private var hotKeyRef: EventHotKeyRef? + + init() { + identifier = HotKey.nextID + HotKey.nextID += 1 + } + + /// False after a failed `register` — e.g. another app already owns the + /// combination (`eventHotKeyExistsErr`). The menu reads this so it never + /// advertises a shortcut that will not fire. + var isRegistered: Bool { hotKeyRef != nil } + + func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) { + unregister() + HotKey.handlers[identifier] = action + HotKey.installHandlerIfNeeded() + + let hotKeyID = EventHotKeyID(signature: HotKey.signature, id: identifier) + var reference: EventHotKeyRef? + let status = RegisterEventHotKey( + keyCode, + modifiers, + hotKeyID, + GetEventDispatcherTarget(), + 0, + &reference + ) + if status == noErr { + hotKeyRef = reference + } else { + HotKey.handlers[identifier] = nil + } + } + + func unregister() { + if let hotKeyRef { + UnregisterEventHotKey(hotKeyRef) + } + hotKeyRef = nil + HotKey.handlers[identifier] = nil + } + + deinit { + unregister() + } + + // MARK: - Carbon plumbing + + /// 'WKFL' + private static let signature: OSType = 0x574B_464C + + private static func installHandlerIfNeeded() { + guard !handlerInstalled else { return } + + var spec = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + + let status = InstallEventHandler( + GetEventDispatcherTarget(), + { _, event, _ -> OSStatus in + guard let event else { return OSStatus(eventNotHandledErr) } + var hotKeyID = EventHotKeyID() + let result = GetEventParameter( + event, + EventParamName(kEventParamDirectObject), + EventParamType(typeEventHotKeyID), + nil, + MemoryLayout.size, + nil, + &hotKeyID + ) + guard result == noErr, hotKeyID.signature == HotKey.signature else { + return OSStatus(eventNotHandledErr) + } + HotKey.handlers[hotKeyID.id]?() + return noErr + }, + 1, + &spec, + nil, + nil + ) + + handlerInstalled = (status == noErr) + } +} diff --git a/Sources/Wakeful/LaunchAtLogin.swift b/Sources/Wakeful/LaunchAtLogin.swift new file mode 100644 index 0000000..7341830 --- /dev/null +++ b/Sources/Wakeful/LaunchAtLogin.swift @@ -0,0 +1,68 @@ +import Foundation +import ServiceManagement + +/// Launch at Login via `SMAppService` (macOS 13+). +/// +/// This registers the app bundle itself as a login item — no helper target, no +/// LaunchAgent plist to maintain. Two practical caveats: +/// +/// 1. The bundle must be code-signed. `make app` ad-hoc signs it, which is +/// enough for local use. +/// 2. The registration records the bundle's path, so install to /Applications +/// *before* enabling this. Moving the app afterwards breaks it. +enum LaunchAtLogin { + + enum State { + case enabled + case disabled + case requiresApproval + case unavailable + } + + static var state: State { + switch SMAppService.mainApp.status { + case .enabled: return .enabled + case .notRegistered: return .disabled + case .requiresApproval: return .requiresApproval + case .notFound: return .unavailable + @unknown default: return .unavailable + } + } + + static var isEnabled: Bool { + state == .enabled || state == .requiresApproval + } + + /// Returns nil on success, or a message suitable for an alert. + /// + /// `register()` throws if already registered and `unregister()` throws if + /// not, so both are guarded — otherwise a double-tap surfaces a raw + /// "Operation not permitted" to the user. + @discardableResult + static func set(_ enabled: Bool) -> String? { + let current = SMAppService.mainApp.status + do { + if enabled { + guard current != .enabled, current != .requiresApproval else { return nil } + try SMAppService.mainApp.register() + } else { + guard current != .notRegistered, current != .notFound else { return nil } + try SMAppService.mainApp.unregister() + } + } catch { + return error.localizedDescription + } + + if enabled, state == .requiresApproval { + return "macOS needs you to approve Wakeful in " + + "System Settings ▸ General ▸ Login Items & Extensions." + } + return nil + } + + /// True when running from a real bundle; login items cannot be registered + /// for a bare `swift run` binary. + static var isRunningFromBundle: Bool { + Bundle.main.bundleIdentifier != nil + } +} diff --git a/Sources/Wakeful/PowerMonitor.swift b/Sources/Wakeful/PowerMonitor.swift new file mode 100644 index 0000000..6ff9254 --- /dev/null +++ b/Sources/Wakeful/PowerMonitor.swift @@ -0,0 +1,114 @@ +import AppKit +import IOKit.ps + +/// Watches the two things the smart-activation rules care about: power source +/// (AC vs battery, plus charge level) and whether an external display is attached. +final class PowerMonitor { + + /// Called on the main thread whenever power state or screen layout changes. + var onChange: (() -> Void)? + + private(set) var isOnAC: Bool = true + private(set) var batteryPercent: Int? + + private var runLoopSource: CFRunLoopSource? + private var screenObserver: NSObjectProtocol? + + /// Desktops and Mac minis have no battery; treat them as permanently on AC. + private(set) var hasBattery: Bool = false + + var externalDisplayConnected: Bool { + NSScreen.screens.count > 1 + } + + func start() { + guard runLoopSource == nil else { return } + refresh() + + let context = Unmanaged.passUnretained(self).toOpaque() + let callback: IOPowerSourceCallbackType = { rawContext in + guard let rawContext else { return } + let monitor = Unmanaged.fromOpaque(rawContext).takeUnretainedValue() + monitor.refresh() + monitor.onChange?() + } + + if let source = IOPSNotificationCreateRunLoopSource(callback, context)?.takeRetainedValue() { + runLoopSource = source + // .commonModes, not .defaultMode: otherwise power changes are dropped + // while a menu is tracking or an alert is up. + CFRunLoopAddSource(CFRunLoopGetMain(), source, CFRunLoopMode.commonModes) + } + + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.onChange?() + } + } + + func stop() { + if let runLoopSource { + CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, CFRunLoopMode.commonModes) + } + runLoopSource = nil + if let screenObserver { + NotificationCenter.default.removeObserver(screenObserver) + } + screenObserver = nil + } + + /// Reads a fresh power-source snapshot. + /// + /// AC state is derived from the internal battery's own power-source state + /// rather than `IOPSGetProvidingPowerSourceType`, which keeps the Unmanaged + /// ownership rules unambiguous. + func refresh() { + guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(), + let list = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as NSArray? + else { + hasBattery = false + isOnAC = true + batteryPercent = nil + return + } + + var foundBattery = false + var onAC = true + var percent: Int? + + for element in list { + let source = element as CFTypeRef + guard let description = + IOPSGetPowerSourceDescription(snapshot, source)?.takeUnretainedValue() + as? [String: Any] + else { continue } + + guard let type = description[kIOPSTypeKey] as? String, + type == kIOPSInternalBatteryType else { continue } + + foundBattery = true + + if let state = description[kIOPSPowerSourceStateKey] as? String { + onAC = (state == kIOPSACPowerValue) + } + + if let current = description[kIOPSCurrentCapacityKey] as? Int, + let maximum = description[kIOPSMaxCapacityKey] as? Int, + maximum > 0 { + percent = Int((Double(current) / Double(maximum) * 100.0).rounded()) + } + break + } + + hasBattery = foundBattery + isOnAC = foundBattery ? onAC : true + batteryPercent = percent + } + + deinit { + stop() + } +} diff --git a/Sources/Wakeful/Preferences.swift b/Sources/Wakeful/Preferences.swift new file mode 100644 index 0000000..41bdc06 --- /dev/null +++ b/Sources/Wakeful/Preferences.swift @@ -0,0 +1,96 @@ +import Foundation + +/// UserDefaults-backed settings. Every key is also settable from the terminal, +/// e.g. `defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60` +final class Preferences { + + static let shared = Preferences() + + private enum Key { + static let assertions = "assertions" + static let defaultDurationMinutes = "defaultDurationMinutes" + static let activateOnLaunch = "activateOnLaunch" + static let showCountdown = "showCountdown" + static let smartOnAC = "smartOnAC" + static let smartOnExternalDisplay = "smartOnExternalDisplay" + static let smartBatteryThreshold = "smartBatteryThreshold" + static let hotKeyIndex = "hotKeyIndex" + } + + private let store = UserDefaults.standard + + private init() { + store.register(defaults: [ + // Matches `caffeinate -dis`, which is what most people actually want. + Key.assertions: [ + AssertionKind.displaySleep.rawValue, + AssertionKind.idleSystemSleep.rawValue, + AssertionKind.systemSleep.rawValue + ], + Key.defaultDurationMinutes: 0, // 0 == until turned off + Key.activateOnLaunch: false, + Key.showCountdown: true, + Key.smartOnAC: false, + Key.smartOnExternalDisplay: false, + Key.smartBatteryThreshold: 0, // 0 == rule disabled + // Off by default — claiming a system-wide shortcut without being + // asked is rude, and it may collide with something already bound. + Key.hotKeyIndex: -1 // index into HotKeyPreset.all, -1 == off + ]) + } + + /// Which assertions to hold while awake. Never allowed to be empty. + var assertions: Set { + get { + let raw = store.stringArray(forKey: Key.assertions) ?? [] + let parsed = Set(raw.compactMap { AssertionKind(rawValue: $0) }) + return parsed.isEmpty ? [.idleSystemSleep] : parsed + } + set { + let safe = newValue.isEmpty ? Set([.idleSystemSleep]) : newValue + store.set(safe.map { $0.rawValue }, forKey: Key.assertions) + } + } + + /// Duration used by the menu bar toggle and the global shortcut. + /// 0 means indefinite. Not exposed in the menu; set it with `defaults write`. + var defaultDurationMinutes: Int { + get { store.integer(forKey: Key.defaultDurationMinutes) } + set { store.set(newValue, forKey: Key.defaultDurationMinutes) } + } + + var activateOnLaunch: Bool { + get { store.bool(forKey: Key.activateOnLaunch) } + set { store.set(newValue, forKey: Key.activateOnLaunch) } + } + + var showCountdown: Bool { + get { store.bool(forKey: Key.showCountdown) } + set { store.set(newValue, forKey: Key.showCountdown) } + } + + var smartOnAC: Bool { + get { store.bool(forKey: Key.smartOnAC) } + set { store.set(newValue, forKey: Key.smartOnAC) } + } + + var smartOnExternalDisplay: Bool { + get { store.bool(forKey: Key.smartOnExternalDisplay) } + set { store.set(newValue, forKey: Key.smartOnExternalDisplay) } + } + + /// Stay awake while the battery is at or above this percentage. 0 disables. + var smartBatteryThreshold: Int { + get { store.integer(forKey: Key.smartBatteryThreshold) } + set { store.set(newValue, forKey: Key.smartBatteryThreshold) } + } + + var hotKeyIndex: Int { + get { store.integer(forKey: Key.hotKeyIndex) } + set { store.set(newValue, forKey: Key.hotKeyIndex) } + } + + var anySmartRuleEnabled: Bool { + smartOnAC || smartOnExternalDisplay || smartBatteryThreshold > 0 + } +} diff --git a/Sources/Wakeful/SleepBlocker.swift b/Sources/Wakeful/SleepBlocker.swift new file mode 100644 index 0000000..2c5f22c --- /dev/null +++ b/Sources/Wakeful/SleepBlocker.swift @@ -0,0 +1,102 @@ +import Foundation +import IOKit.pwr_mgt + +/// The IOKit power assertions that map onto `caffeinate` flags. +/// +/// `caffeinate` is itself a thin wrapper around `IOPMAssertionCreateWithName`, +/// so holding these assertions in-process is exactly equivalent to keeping a +/// `caffeinate` process alive — minus the terminal window and the orphan risk. +enum AssertionKind: String, CaseIterable { + + /// `caffeinate -d` — the display will not dim or sleep on idle. + case displaySleep = "PreventUserIdleDisplaySleep" + + /// `caffeinate -i` — the system will not sleep on idle (display may still sleep). + case idleSystemSleep = "PreventUserIdleSystemSleep" + + /// `caffeinate -s` — the system will not enter full sleep (dark wake is still + /// allowed). Only honoured while running on AC power. + /// + /// Note: power assertions never override *user-initiated* sleep, so this does + /// NOT keep the Mac up with the lid closed. Clamshell sleep can only be + /// defeated by `sudo pmset disablesleep 1`, which has no public API. + case systemSleep = "PreventSystemSleep" + + /// `caffeinate -m` — disks will not spin down on idle. + case diskIdle = "PreventDiskIdle" + + /// Order used when rendering the Prevent submenu. + static let displayOrder: [AssertionKind] = [ + .displaySleep, .idleSystemSleep, .systemSleep, .diskIdle + ] + + var title: String { + switch self { + case .displaySleep: return "Display sleep" + case .idleSystemSleep: return "System idle sleep" + case .systemSleep: return "Full system sleep (AC only)" + case .diskIdle: return "Disk spin-down" + } + } + + var caffeinateFlag: String { + switch self { + case .displaySleep: return "d" + case .idleSystemSleep: return "i" + case .systemSleep: return "s" + case .diskIdle: return "m" + } + } +} + +/// Owns the live IOKit power assertions. +/// +/// `apply(_:)` is idempotent and declarative: it reconciles the set of held +/// assertions with the set you ask for, creating and releasing only the delta. +/// The kernel releases every assertion automatically when the process exits, +/// so a crash can never leave the Mac permanently awake. +final class SleepBlocker { + + private var held: [AssertionKind: IOPMAssertionID] = [:] + + /// Human-readable text shown in `pmset -g assertions`. + private let reason = "Wakeful is keeping this Mac awake" + + private(set) var lastError: String? + + var isBlocking: Bool { !held.isEmpty } + + var heldKinds: Set { Set(held.keys) } + + /// Makes the set of live assertions exactly equal to `desired`. + func apply(_ desired: Set) { + lastError = nil + for (kind, id) in held where !desired.contains(kind) { + _ = IOPMAssertionRelease(id) + held.removeValue(forKey: kind) + } + + for kind in desired where held[kind] == nil { + var id: IOPMAssertionID = IOPMAssertionID(0) + let result = IOPMAssertionCreateWithName( + kind.rawValue as CFString, + IOPMAssertionLevel(kIOPMAssertionLevelOn), + reason as CFString, + &id + ) + if result == kIOReturnSuccess { + held[kind] = id + } else { + lastError = "Could not create \(kind.rawValue) (IOReturn \(result))" + } + } + } + + func releaseAll() { + apply([]) + } + + deinit { + releaseAll() + } +} diff --git a/Sources/Wakeful/WakeController.swift b/Sources/Wakeful/WakeController.swift new file mode 100644 index 0000000..0de504d --- /dev/null +++ b/Sources/Wakeful/WakeController.swift @@ -0,0 +1,216 @@ +import Foundation + +/// The state machine. Owns the assertions, the session timer, and the +/// interaction between manual intent and the smart-activation rules. +final class WakeController { + + enum Source { + case manual + case automatic + } + + /// Called on the main thread whenever anything the menu displays changes. + var onChange: (() -> Void)? + + private let blocker = SleepBlocker() + private let prefs = Preferences.shared + private weak var power: PowerMonitor? + + private(set) var isOn = false + private(set) var source: Source = .manual + private(set) var expiry: Date? + private(set) var automaticReason: String? + + /// Set when the user manually turns Wakeful off while a smart rule wanted it + /// on. Without this, unplugging is the only way to make the app stop + /// re-enabling itself, which feels broken. Cleared as soon as the rules stop + /// being satisfied. + private var suppressAutomatic = false + + private var ticker: Timer? + + func attach(power: PowerMonitor) { + self.power = power + } + + // MARK: - Derived state + + var remaining: TimeInterval? { + guard let expiry else { return nil } + return max(0, expiry.timeIntervalSinceNow) + } + + var heldKinds: Set { blocker.heldKinds } + + var lastError: String? { blocker.lastError } + + var statusText: String { + guard isOn else { + return prefs.anySmartRuleEnabled ? "Sleep allowed — watching conditions" + : "Sleep allowed" + } + if let remaining { + return "Awake — \(WakeController.format(remaining)) left" + } + if source == .automatic, let automaticReason { + return "Awake — \(automaticReason)" + } + return "Awake — until turned off" + } + + // MARK: - Intent + + func toggle() { + if isOn { + turnOff(source: .manual) + } else { + turnOn(minutes: prefs.defaultDurationMinutes, source: .manual) + } + } + + func turnOn(minutes: Int, source: Source, reason: String? = nil) { + blocker.apply(prefs.assertions) + isOn = blocker.isBlocking + + // If every assertion failed, don't arm a phantom session with a ticking + // countdown while nothing is actually being held. lastError explains why. + guard isOn else { + setExpiry(nil) + automaticReason = nil + onChange?() + return + } + + self.source = source + automaticReason = reason + if source == .manual { + suppressAutomatic = false + } + setExpiry(minutes > 0 ? Date().addingTimeInterval(TimeInterval(minutes) * 60) : nil) + onChange?() + } + + /// Adds exactly `minutes` to a running timed session, without disturbing the + /// session's source, reason, assertions, or suppression state. + func extend(by minutes: Int) { + guard isOn, let expiry else { return } + setExpiry(expiry.addingTimeInterval(TimeInterval(minutes) * 60)) + onChange?() + } + + func turnOff(source: Source) { + if source == .manual, isOn, automaticRuleSatisfied().satisfied { + suppressAutomatic = true + } + blocker.releaseAll() + isOn = false + self.source = source + automaticReason = nil + setExpiry(nil) + onChange?() + } + + /// Re-applies the current assertion set. Called after the user changes the + /// Prevent submenu while a session is running. + func reapplyAssertions() { + guard isOn else { + onChange?() + return + } + blocker.apply(prefs.assertions) + isOn = blocker.isBlocking + onChange?() + } + + // MARK: - Smart activation + + func automaticRuleSatisfied() -> (satisfied: Bool, reason: String?) { + guard let power, prefs.anySmartRuleEnabled else { return (false, nil) } + + if prefs.smartOnAC, power.isOnAC { + return (true, "plugged in") + } + if prefs.smartOnExternalDisplay, power.externalDisplayConnected { + return (true, "external display connected") + } + // Gated on !isOnAC deliberately: the point of this rule is "stay awake + // while there's charge to spare". Without the gate, a plugged-in laptop + // sitting at 100% would satisfy it permanently. + let threshold = prefs.smartBatteryThreshold + if threshold > 0, !power.isOnAC, + let percent = power.batteryPercent, percent >= threshold { + return (true, "battery at \(percent)%") + } + return (false, nil) + } + + /// Called on launch and on every power / display change. + func evaluateAutomatic() { + let rule = automaticRuleSatisfied() + + if !rule.satisfied { + suppressAutomatic = false + } + + if rule.satisfied, !isOn, !suppressAutomatic { + turnOn(minutes: 0, source: .automatic, reason: rule.reason) + } else if !rule.satisfied, isOn, source == .automatic { + turnOff(source: .automatic) + } else if rule.satisfied, isOn, source == .automatic, + automaticReason != rule.reason { + automaticReason = rule.reason + onChange?() + } + } + + /// Called when the smart rules themselves are edited. + func smartRulesChanged() { + if !prefs.anySmartRuleEnabled, isOn, source == .automatic { + turnOff(source: .automatic) + return + } + evaluateAutomatic() + onChange?() + } + + // MARK: - Session timer + + private func setExpiry(_ date: Date?) { + expiry = date + ticker?.invalidate() + ticker = nil + guard date != nil else { return } + let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in + self?.tick() + } + // .common lets the countdown keep updating while a menu is open. + RunLoop.main.add(timer, forMode: .common) + ticker = timer + } + + private func tick() { + guard let expiry else { return } + if Date() >= expiry { + // Pass the *current* source through: an expiring manual session must + // arm suppressAutomatic, or a satisfied smart rule would flip it + // straight back on and "Keep Awake For 15 minutes" would never end. + turnOff(source: source) + evaluateAutomatic() + } else { + onChange?() + } + } + + // MARK: - Helpers + + static func format(_ interval: TimeInterval) -> String { + let total = Int(interval.rounded()) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let seconds = total % 60 + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/Sources/Wakeful/main.swift b/Sources/Wakeful/main.swift new file mode 100644 index 0000000..1800320 --- /dev/null +++ b/Sources/Wakeful/main.swift @@ -0,0 +1,10 @@ +import AppKit + +// Wakeful has no windows and no Dock icon. `LSUIElement` in Info.plist covers +// the bundled case; setting the activation policy here also covers running the +// raw binary from a terminal during development. +let application = NSApplication.shared +let delegate = AppDelegate() +application.delegate = delegate +application.setActivationPolicy(.accessory) +application.run()