initial commit
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user