- Detect an external display as any online display that isn't built in (CGGetOnlineDisplayList + CGDisplayIsBuiltin) instead of counting NSScreens, so the rule works in clamshell mode, when mirroring and on single-monitor desktops. The Smart Activation footer reports the same check. - Refuse and mark shortcut presets that clash with a macOS system shortcut (CopySymbolicHotKeys, checked once, only when the Global Shortcut submenu opens), report the real reason a registration failed, and stop claiming that other apps' clashes are detected. Show the shortcut hint on the toggle only when it is actually registered. - Make the menu toggle carry the action it displays, refresh the status line, toggle and "Add 30 minutes" while the menu is open, and ignore a click that lands as the toggle changes meaning, so a click never does the opposite of its label. - Replace the single suppression flag with per-rule overrides: the status names what is paused, Turn On becomes Resume Smart Activation, each rule re-arms when it stops holding, and a rule that became true during a timed session takes over at expiry while rules that held throughout stay paused. - Update the README to match, bump the version to 1.1.0 and add CHANGELOG.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
241 lines
10 KiB
Markdown
241 lines
10 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 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).
|