Release 1.1.0: fix display rule, shortcut clashes, stale menu and smart activation

- 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>
This commit is contained in:
Gary Hansen
2026-09-23 18:08:35 +10:00
co-authored by Claude Opus 5.5
parent 2363fc2165
commit ee2e22b2f7
8 changed files with 430 additions and 90 deletions
+44 -17
View File
@@ -72,8 +72,13 @@ 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.
`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 }`
@@ -82,14 +87,27 @@ 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`):
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`.
- 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.
- 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
@@ -143,26 +161,35 @@ Preferences and Launch at Login only behave correctly from the assembled bundle.
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). 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.
*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. To
make them start a timed one instead:
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