Files
Wakeful/README.md
T
2026-09-15 12:44:18 +10:00

214 lines
8.6 KiB
Markdown

# 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).