initial commit

This commit is contained in:
Gary Hansen
2026-09-15 12:44:18 +10:00
commit 2363fc2165
15 changed files with 1503 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.build/
dist/
.swiftpm/
*.xcodeproj
*.xcworkspace
.DS_Store
Package.resolved
+24
View File
@@ -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"
}
]
}
+21
View File
@@ -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.
+78
View File
@@ -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)"
+22
View File
@@ -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"
)
]
)
+213
View File
@@ -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 <your-repo-url> 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).
+34
View File
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>Wakeful</string>
<key>CFBundleIdentifier</key>
<string>__BUNDLE_ID__</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Wakeful</string>
<key>CFBundleDisplayName</key>
<string>Wakeful</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>__VERSION__</string>
<key>CFBundleVersion</key>
<string>__VERSION__</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>MIT License</string>
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>NSSupportsSuddenTermination</key>
<false/>
</dict>
</plist>
+368
View File
@@ -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()
}
}
+130
View File
@@ -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<EventHotKeyID>.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)
}
}
+68
View File
@@ -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
}
}
+114
View File
@@ -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<PowerMonitor>.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()
}
}
+96
View File
@@ -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<AssertionKind> {
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<AssertionKind>([.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
}
}
+102
View File
@@ -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<AssertionKind> { Set(held.keys) }
/// Makes the set of live assertions exactly equal to `desired`.
func apply(_ desired: Set<AssertionKind>) {
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()
}
}
+216
View File
@@ -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<AssertionKind> { 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)
}
}
+10
View File
@@ -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()