scripts/check.sh (also `make check`) parses the Swift sources, validates Info.plist and its placeholders, checks the Makefile VERSION against the newest CHANGELOG release, and flags whitespace errors and conflict markers. The lint job runs it on the existing Linux runners in a swift:6.3 container. A full macOS build job is included but skipped until a macOS runner is registered and the MACOS_RUNNER repo variable is set to true. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
Markdown
242 lines
11 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 prevents desync between openings, and a menu
|
|
opens rarely enough that the cost is irrelevant. State can still change while
|
|
the menu is open (a timed session expires, you plug in), so the few items whose
|
|
meaning depends on it — the status line, the Turn On / Turn Off toggle and
|
|
"Add 30 minutes" — are refreshed live, and the toggle carries the action it
|
|
displays. A click never does the opposite of its label: if the toggle changed
|
|
meaning just as you clicked, the click is ignored.
|
|
|
|
**`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`),
|
|
and which rules you have overridden:
|
|
|
|
- A rule becoming true turns Wakeful on with source `.automatic`.
|
|
- When the last rule you haven't overridden stops holding, it turns 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 overrides that rule, so
|
|
the app doesn't immediately re-enable itself. The status line names what is
|
|
paused (e.g. "Sleep allowed — AC rule paused"), and Turn On (and the global
|
|
shortcut) becomes **Resume Smart Activation**, which hands control back to the
|
|
rules. Only rules that hold at that moment are overridden; the others stay
|
|
armed. With the AC and battery rules both on, turning off at your desk and
|
|
then unplugging lets the battery rule switch Wakeful back on.
|
|
- Each overridden rule re-arms on its own as soon as it stops being satisfied —
|
|
so unplugging and replugging re-arms the AC rule even while an external
|
|
display keeps another rule true. Enabling a rule arms it too.
|
|
- Starting a manual session overrides the rules that hold at that moment. When
|
|
a timed session ends, a rule that held throughout stays overridden — so "Keep
|
|
Awake For 15 minutes" really ends, even on AC — but a rule that became true
|
|
during the session (you docked halfway through) takes over.
|
|
|
|
### 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 check` | Run the basic checks CI runs on every push |
|
|
| `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.
|
|
While Smart Activation is paused this reads **Resume Smart Activation**.
|
|
- **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). Any display
|
|
that isn't the built-in panel counts, so the rule also covers a closed laptop
|
|
driving a monitor, mirroring to a projector, and a desktop Mac's only monitor.
|
|
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. Presets that
|
|
clash with one of macOS's own system shortcuts (screenshots, Spotlight,
|
|
Mission Control, input sources and the like, in System Settings ▸ Keyboard ▸
|
|
Keyboard Shortcuts) are marked and can't be picked; the preset you've chosen
|
|
is checked when you pick it and at each launch. App Shortcuts, Services
|
|
shortcuts and other apps' shortcuts can't be detected: macOS lets several apps
|
|
register the same combination and delivers it to all of them, so if a preset
|
|
already does something elsewhere, pick a different one. 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
|
|
(while Smart Activation is paused they resume it instead). To make them start a
|
|
timed one:
|
|
|
|
```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).
|