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
+60
View File
@@ -0,0 +1,60 @@
# Changelog
All notable changes to Wakeful are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).
## [1.1.0] - 2026-09-23
### Fixed
- **External display rule works where it matters.** It now looks for any
connected display that isn't the built-in panel, instead of counting screens,
so it fires with the lid closed (clamshell), when mirroring to a projector,
and on a desktop Mac's only monitor. Docking and closing the lid no longer
switches Wakeful off. The Smart Activation footer shows the same check
("external display" / "no external display") instead of a screen count.
- **Global shortcut clashes with macOS shortcuts are detected.** Presets that
clash with a system shortcut (screenshots, Spotlight, Mission Control, input
sources…) are marked "used by a macOS shortcut" and can't be picked, and a
failed registration shows its real reason. 1.0.0 promised an "unavailable"
warning that could never appear. Clashes with App Shortcuts, Services and
other apps can't be detected by any public API; the README now says so.
- **An open menu no longer goes stale.** The status line, Turn On / Turn Off
and "Add 30 minutes" update while the menu is open, and the toggle always
does what its label said. Previously, clicking "Turn Off" as a timed session
expired started an indefinite session. A click that lands just as the label
changes is now ignored.
- **Smart Activation after a manual override:**
- Turning Wakeful off while a rule applies now says what is paused
("Sleep allowed — AC rule paused") instead of "watching conditions".
- Each paused rule re-arms as soon as its condition stops holding, so
unplugging and replugging re-arms the AC rule even with other rules on.
- A rule that becomes true during a timed session (you dock halfway through)
takes over when the session ends. A rule that held throughout stays paused,
so "Keep Awake For 15 minutes" still really ends on AC.
- Turning off and back on no longer converts an automatic session into an
indefinite manual one that survives unplugging.
- The shortcut hint on Turn On / Turn Off (e.g. "(⌃⌥⌘K)") only appears when
that shortcut is actually registered.
### Changed
- While Smart Activation is paused, Turn On and the global shortcut become
**Resume Smart Activation**, handing control back to the rules. In that state
they don't use `defaultDurationMinutes`; use Keep Awake For to start a manual
session.
- Turn Off overrides only the rules that hold at that moment. With the AC and
battery rules both on, turning off at your desk and then unplugging lets the
battery rule switch Wakeful back on; 1.0.0 stayed off.
- A deliberate click on the toggle within about half a second of its label
changing is ignored and needs repeating.
- The chosen shortcut preset is checked for system-shortcut clashes when you
pick it and at each launch; a clash created later is noticed at the next
launch.
- README: smart activation semantics, the menu design notes and the global
shortcut limitations rewritten to match the code.
## [1.0.0]
- Initial release.
+1 -1
View File
@@ -1,6 +1,6 @@
APP_NAME := Wakeful APP_NAME := Wakeful
BUNDLE_ID := com.github.wakeful.Wakeful BUNDLE_ID := com.github.wakeful.Wakeful
VERSION := 1.0.0 VERSION := 1.1.0
# Override with: make SIGN_ID="Developer ID Application: Your Name (TEAMID)" # Override with: make SIGN_ID="Developer ID Application: Your Name (TEAMID)"
SIGN_ID ?= - SIGN_ID ?= -
+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 **The menu is rebuilt from scratch in `menuNeedsUpdate(_:)`.** Menu bar apps
accumulate bugs from trying to keep dozens of checkmarks in sync with model accumulate bugs from trying to keep dozens of checkmarks in sync with model
state. Throwing the menu away and regenerating it from `Preferences` and state. Throwing the menu away and regenerating it from `Preferences` and
`WakeController` on each open makes desync structurally impossible, and a menu `WakeController` on each open prevents desync between openings, and a menu
opens rarely enough that the cost is irrelevant. 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 **`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 }` 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 ### Smart activation semantics
The tricky part of conditional activation is not the conditions, it's not 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 true turns Wakeful on with source `.automatic`.
- A rule becoming false turns it off again **only if** source is `.automatic`. - When the last rule you haven't overridden stops holding, it turns off again
Your manual session is never cancelled by a rule. **only if** source is `.automatic`. Your manual session is never cancelled by
- Manually turning it **off** while a rule wants it on sets a suppression flag, a rule.
so the app doesn't immediately re-enable itself. The flag clears as soon as - Manually turning it **off** while a rule wants it on overrides that rule, so
the rule stops being satisfied — so unplugging and replugging re-arms it. 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 ### 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. Click the cup icon in the menu bar.
- **Turn On / Turn Off** — indefinite session. Same as the global shortcut. - **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 - **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. 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. - **Prevent** — pick which assertions to hold. At least one is always held.
- **Smart Activation** — auto-enable while on AC, while an external display is - **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 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 *off* AC, otherwise a charged laptop would satisfy it forever). Any display
footer shows current conditions so you can see why a rule fired. that isn't the built-in panel counts, so the rule also covers a closed laptop
- **Global Shortcut** — off by default; pick a preset to enable it. If the driving a monitor, mirroring to a projector, and a desktop Mac's only monitor.
combination is already claimed by another app or a System Settings shortcut, The submenu footer shows current conditions so you can see why a rule fired.
the menu says "unavailable" rather than silently doing nothing. Note that no - **Global Shortcut** — off by default; pick a preset to enable it. Presets that
global shortcut fires while macOS Secure Input is active — i.e. while a clash with one of macOS's own system shortcuts (screenshots, Spotlight,
password field has focus. 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 - **Launch at Login** — install to `/Applications` *first*; the registration
records the bundle path, so moving the app afterwards breaks it. records the bundle path, so moving the app afterwards breaks it.
- **Turn On at Launch** — combine with Launch at Login for always-awake. - **Turn On at Launch** — combine with Launch at Login for always-awake.
### Hidden preference ### Hidden preference
The toggle and the global shortcut start an indefinite session by default. To The toggle and the global shortcut start an indefinite session by default
make them start a timed one instead: (while Smart Activation is paused they resume it instead). To make them start a
timed one:
```sh ```sh
defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60 defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60
+124 -23
View File
@@ -10,8 +10,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem! private var statusItem: NSStatusItem!
private let menu = NSMenu() private let menu = NSMenu()
/// Held so the countdown in the status line stays live while the menu is open. /// Held so the items whose meaning depends on live state — the countdown in
/// the status line, the toggle, and "Add 30 minutes" — stay current while
/// the menu is open.
private weak var statusLineItem: NSMenuItem? private weak var statusLineItem: NSMenuItem?
private weak var toggleItem: NSMenuItem?
private weak var extendSeparator: NSMenuItem?
private weak var extendItem: NSMenuItem?
/// The Global Shortcut submenu, held so menuNeedsUpdate can tell it from the
/// main menu and mark system-shortcut clashes when it opens, instead of
/// rebuilding. Its markings aren't refreshed while it stays open.
private weak var shortcutMenu: NSMenu?
/// When the open menu's toggle last changed meaning. AppKit sends a menu
/// item's action about a third of a second after the click, once the item
/// has flashed and the menu has faded, so a click aimed at the old label
/// can arrive carrying the new one.
private var toggleChangedAt: TimeInterval = -.infinity
/// How long after the toggle changes meaning a click on it is ignored,
/// measured when the action arrives: ~0.35–0.4 s of dispatch delay, plus
/// ~0.4 s for a click that was already on its way when the label changed.
private static let toggleSettleTime: TimeInterval = 0.8
/// The preset that is actually registered, so the menu never advertises a
/// shortcut that isn't live.
private var activeHotKeyIndex: Int?
/// Durations offered in the "Keep awake for" submenu, in minutes. /// Durations offered in the "Keep awake for" submenu, in minutes.
private let durations: [(String, Int)] = [ private let durations: [(String, Int)] = [
@@ -80,18 +105,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
button.toolTip = "Wakeful — " + controller.statusText button.toolTip = "Wakeful — " + controller.statusText
// Keep an already-open menu's status line ticking too. // Keep an already-open menu honest: the status line ticks, and items
// whose meaning depends on state are re-derived, so a click always does
// what its label says.
statusLineItem?.title = controller.statusText statusLineItem?.title = controller.statusText
if let toggleItem {
let before = toggleItem.tag
configureToggle(toggleItem)
if toggleItem.tag != before {
toggleChangedAt = ProcessInfo.processInfo.systemUptime
}
}
// Disabled rather than hidden once the session ends: hiding a
// highlighted item leaves a sliver of highlight behind.
let timed = controller.remaining != nil
if timed {
extendSeparator?.isHidden = false
extendItem?.isHidden = false
}
extendItem?.isEnabled = timed
} }
// MARK: - Menu construction // MARK: - Menu construction
func menuNeedsUpdate(_ menu: NSMenu) { func menuNeedsUpdate(_ menu: NSMenu) {
rebuild(menu) if menu === shortcutMenu {
markSystemShortcuts(in: menu)
} else {
rebuild(menu)
}
} }
private func rebuild(_ menu: NSMenu) { private func rebuild(_ menu: NSMenu) {
menu.removeAllItems() menu.removeAllItems()
toggleChangedAt = -.infinity
// Status line // Status line
let status = NSMenuItem(title: controller.statusText, action: nil, keyEquivalent: "") let status = NSMenuItem(title: controller.statusText, action: nil, keyEquivalent: "")
@@ -108,17 +155,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.addItem(.separator()) menu.addItem(.separator())
// Primary toggle // Primary toggle
let toggle = item( let toggle = item("", #selector(toggleNow(_:)))
controller.isOn ? "Turn Off" : "Turn On", configureToggle(toggle)
#selector(toggleNow(_:))
)
// Only advertise the shortcut if it actually registered.
if prefs.hotKeyIndex >= 0, prefs.hotKeyIndex < HotKeyPreset.all.count,
hotKey.isRegistered {
let preset = HotKeyPreset.all[prefs.hotKeyIndex]
toggle.title += " (\(preset.label))"
}
menu.addItem(toggle) menu.addItem(toggle)
toggleItem = toggle
// Timed sessions // Timed sessions
let durationMenu = NSMenu() let durationMenu = NSMenu()
@@ -128,10 +168,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
entry.state = (minutes == prefs.defaultDurationMinutes) ? .on : .off entry.state = (minutes == prefs.defaultDurationMinutes) ? .on : .off
durationMenu.addItem(entry) durationMenu.addItem(entry)
} }
if controller.remaining != nil { // Always present, hidden outside a timed session. While the menu is
durationMenu.addItem(.separator()) // open, refreshStatusItem reveals it if a session starts and only
durationMenu.addItem(item("Add 30 minutes", #selector(extendSession(_:)), tag: 30)) // disables it when one ends (see there); the next rebuild hides it.
} let timed = controller.remaining != nil
let separator = NSMenuItem.separator()
separator.isHidden = !timed
durationMenu.addItem(separator)
let extend = item("Add 30 minutes", #selector(extendSession(_:)), tag: 30)
extend.isHidden = !timed
extend.isEnabled = timed
durationMenu.addItem(extend)
extendSeparator = separator
extendItem = extend
menu.addItem(submenu("Keep Awake For", durationMenu)) menu.addItem(submenu("Keep Awake For", durationMenu))
menu.addItem(.separator()) menu.addItem(.separator())
@@ -180,13 +229,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
smartMenu.addItem(submenu("While battery is…", batteryMenu)) smartMenu.addItem(submenu("While battery is…", batteryMenu))
smartMenu.addItem(.separator()) smartMenu.addItem(.separator())
// Report exactly what the display rule tests, so the footer can't
// disagree with it.
let display = power.externalDisplayConnected ? "external display" : "no external display"
let conditions: String let conditions: String
if power.hasBattery { if power.hasBattery {
let charge = power.batteryPercent.map { "\($0)%" } ?? "unknown" let charge = power.batteryPercent.map { "\($0)%" } ?? "unknown"
conditions = "Now: \(power.isOnAC ? "AC" : "battery"), \(charge), " conditions = "Now: \(power.isOnAC ? "AC" : "battery"), \(charge), \(display)"
+ "\(NSScreen.screens.count) display(s)"
} else { } else {
conditions = "Now: AC (no battery), \(NSScreen.screens.count) display(s)" conditions = "Now: AC (no battery), \(display)"
} }
let conditionsItem = NSMenuItem(title: conditions, action: nil, keyEquivalent: "") let conditionsItem = NSMenuItem(title: conditions, action: nil, keyEquivalent: "")
conditionsItem.isEnabled = false conditionsItem.isEnabled = false
@@ -199,6 +250,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
// Global shortcut // Global shortcut
let shortcutMenu = NSMenu() let shortcutMenu = NSMenu()
shortcutMenu.autoenablesItems = false shortcutMenu.autoenablesItems = false
// Clashes with system shortcuts are marked by markSystemShortcuts when
// this submenu opens, so ordinary menu opens don't pay for the lookup.
shortcutMenu.delegate = self
let noneItem = item("Off", #selector(setHotKey(_:)), tag: -1) let noneItem = item("Off", #selector(setHotKey(_:)), tag: -1)
noneItem.state = prefs.hotKeyIndex < 0 ? .on : .off noneItem.state = prefs.hotKeyIndex < 0 ? .on : .off
shortcutMenu.addItem(noneItem) shortcutMenu.addItem(noneItem)
@@ -206,12 +260,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let selected = prefs.hotKeyIndex == index let selected = prefs.hotKeyIndex == index
let entry = item(preset.label, #selector(setHotKey(_:)), tag: index) let entry = item(preset.label, #selector(setHotKey(_:)), tag: index)
entry.state = selected ? .on : .off entry.state = selected ? .on : .off
if selected, !hotKey.isRegistered { if selected, let reason = hotKey.unavailableReason {
entry.title += " — unavailable (already in use)" entry.title += " — unavailable (\(reason))"
} }
shortcutMenu.addItem(entry) shortcutMenu.addItem(entry)
} }
menu.addItem(submenu("Global Shortcut", shortcutMenu)) menu.addItem(submenu("Global Shortcut", shortcutMenu))
self.shortcutMenu = shortcutMenu
// Startup and display options // Startup and display options
let launchState = LaunchAtLogin.state let launchState = LaunchAtLogin.state
@@ -255,10 +310,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
return entry return entry
} }
/// Sets the toggle's title and the action it performs together, so the two
/// can never disagree.
private func configureToggle(_ toggle: NSMenuItem) {
let action = controller.toggleAction
var title: String
switch action {
case .turnOff: title = "Turn Off"
case .turnOn: title = "Turn On"
case .resumeAutomatic: title = "Resume Smart Activation"
}
if let index = activeHotKeyIndex {
title += " (\(HotKeyPreset.all[index].label))"
}
toggle.tag = action.rawValue
if toggle.title != title {
toggle.title = title
}
}
/// Disables the presets that a macOS system shortcut already uses. The
/// selected preset is left alone: it was checked when it was registered, at
/// launch or when picked, and that registration reports its own reason.
private func markSystemShortcuts(in shortcutMenu: NSMenu) {
let taken = HotKey.systemShortcuts()
for entry in shortcutMenu.items
where entry.tag != prefs.hotKeyIndex && HotKeyPreset.all.indices.contains(entry.tag) {
let preset = HotKeyPreset.all[entry.tag]
let clashes = HotKey.isSystemShortcut(keyCode: preset.keyCode,
modifiers: preset.modifiers, in: taken)
entry.title = clashes ? preset.label + " — used by a macOS shortcut" : preset.label
entry.isEnabled = !clashes
}
}
// MARK: - Actions // MARK: - Actions
@objc private func toggleNow(_ sender: NSMenuItem) { @objc private func toggleNow(_ sender: NSMenuItem) {
controller.toggle() // Do what the item said when it was clicked, not whatever the state
// implies now. A click that arrives just after the toggle changed
// meaning was aimed at the old label, so it's dropped: in every such
// case the old action is already moot (e.g. "Turn Off" as the session
// expired), and doing the new one would be the opposite.
guard ProcessInfo.processInfo.systemUptime - toggleChangedAt > AppDelegate.toggleSettleTime,
let action = WakeController.ToggleAction(rawValue: sender.tag)
else { return }
controller.perform(action)
} }
@objc private func startSession(_ sender: NSMenuItem) { @objc private func startSession(_ sender: NSMenuItem) {
@@ -344,12 +441,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func applyHotKeyPreference() { private func applyHotKeyPreference() {
hotKey.unregister() hotKey.unregister()
activeHotKeyIndex = nil
let index = prefs.hotKeyIndex let index = prefs.hotKeyIndex
guard index >= 0, index < HotKeyPreset.all.count else { return } guard index >= 0, index < HotKeyPreset.all.count else { return }
let preset = HotKeyPreset.all[index] let preset = HotKeyPreset.all[index]
hotKey.register(keyCode: preset.keyCode, modifiers: preset.modifiers) { [weak self] in hotKey.register(keyCode: preset.keyCode, modifiers: preset.modifiers) { [weak self] in
self?.controller.toggle() self?.controller.toggle()
} }
if hotKey.isRegistered {
activeHotKeyIndex = index
}
} }
private func presentAlert(title: String, message: String) { private func presentAlert(title: String, message: String) {
+54 -7
View File
@@ -27,10 +27,17 @@ struct HotKeyPreset {
/// through an Accessibility / Input Monitoring permission prompt. /// through an Accessibility / Input Monitoring permission prompt.
/// `RegisterEventHotKey` needs no entitlement and no permission at all. /// `RegisterEventHotKey` needs no entitlement and no permission at all.
/// ///
/// Two limits are inherent to the API: registration fails with /// Limits inherent to the API:
/// `eventHotKeyExistsErr` if another app or a System Settings shortcut already ///
/// owns the combination, and no hot key fires while macOS Secure Input is /// - Registration is non-exclusive and succeeds even when another app already
/// active (i.e. while a password field has focus). /// uses the combination; both apps then receive the key. Other apps'
/// shortcuts can't be detected, so only macOS's own system shortcuts are
/// checked, via `CopySymbolicHotKeys` — which excludes App Shortcuts and
/// Services key equivalents. `kEventHotKeyExclusive` is not used: it would
/// silently mute another app's shortcut, and it only detects other
/// exclusive registrations.
/// - No hot key fires while macOS Secure Input is active (i.e. while a
/// password field has focus).
final class HotKey { final class HotKey {
/// Keyed by hot key id. Stores the closure rather than the `HotKey` object so /// Keyed by hot key id. Stores the closure rather than the `HotKey` object so
@@ -47,13 +54,20 @@ final class HotKey {
HotKey.nextID += 1 HotKey.nextID += 1
} }
/// False after a failed `register` — e.g. another app already owns the /// The menu reads this so it never advertises a shortcut that isn't live.
/// combination (`eventHotKeyExistsErr`). The menu reads this so it never
/// advertises a shortcut that will not fire.
var isRegistered: Bool { hotKeyRef != nil } var isRegistered: Bool { hotKeyRef != nil }
/// Why the last `register` did not take effect, for the menu. nil when
/// registered or unregistered.
private(set) var unavailableReason: String?
func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) { func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
unregister() unregister()
if HotKey.isSystemShortcut(keyCode: keyCode, modifiers: modifiers,
in: HotKey.systemShortcuts()) {
unavailableReason = "used by a macOS shortcut"
return
}
HotKey.handlers[identifier] = action HotKey.handlers[identifier] = action
HotKey.installHandlerIfNeeded() HotKey.installHandlerIfNeeded()
@@ -71,6 +85,7 @@ final class HotKey {
hotKeyRef = reference hotKeyRef = reference
} else { } else {
HotKey.handlers[identifier] = nil HotKey.handlers[identifier] = nil
unavailableReason = "registration failed, error \(status)"
} }
} }
@@ -79,6 +94,7 @@ final class HotKey {
UnregisterEventHotKey(hotKeyRef) UnregisterEventHotKey(hotKeyRef)
} }
hotKeyRef = nil hotKeyRef = nil
unavailableReason = nil
HotKey.handlers[identifier] = nil HotKey.handlers[identifier] = nil
} }
@@ -86,6 +102,37 @@ final class HotKey {
unregister() unregister()
} }
typealias Combination = (keyCode: UInt32, modifiers: UInt32)
private static let relevantModifiers = UInt32(cmdKey | shiftKey | optionKey | controlKey)
/// Every enabled macOS system shortcut (screenshots, Spotlight, Mission
/// Control, input sources…). `CopySymbolicHotKeys` costs around 10 ms, so
/// take one snapshot and test every preset against it.
static func systemShortcuts() -> [Combination] {
var unmanaged: Unmanaged<CFArray>?
guard CopySymbolicHotKeys(&unmanaged) == noErr,
let shortcuts = unmanaged?.takeRetainedValue() as? [[String: Any]]
else { return [] }
return shortcuts.compactMap { shortcut in
guard (shortcut[kHISymbolicHotKeyEnabled] as? Bool) == true,
let code = shortcut[kHISymbolicHotKeyCode] as? Int,
let mods = shortcut[kHISymbolicHotKeyModifiers] as? Int
else { return nil }
return (UInt32(truncatingIfNeeded: code),
UInt32(truncatingIfNeeded: mods) & relevantModifiers)
}
}
/// Whether a system shortcut already uses this combination. Registration
/// would still succeed, leaving one key press with two meanings.
static func isSystemShortcut(keyCode: UInt32, modifiers: UInt32,
in shortcuts: [Combination]) -> Bool {
let wanted = modifiers & relevantModifiers
return shortcuts.contains { $0.keyCode == keyCode && $0.modifiers == wanted }
}
// MARK: - Carbon plumbing // MARK: - Carbon plumbing
/// 'WKFL' /// 'WKFL'
+17 -1
View File
@@ -17,8 +17,24 @@ final class PowerMonitor {
/// Desktops and Mac minis have no battery; treat them as permanently on AC. /// Desktops and Mac minis have no battery; treat them as permanently on AC.
private(set) var hasBattery: Bool = false private(set) var hasBattery: Bool = false
/// True when any display other than the built-in panel is connected.
///
/// Deliberately not `NSScreen.screens.count > 1`, which is wrong exactly
/// where the rule matters: in clamshell mode the built-in panel leaves
/// `NSScreen.screens`, a mirror set is a single NSScreen, and a desktop
/// Mac's only monitor is external. The online list has every connected
/// display, hardware mirrors included. DisplayLink, Sidecar and AirPlay
/// displays are not built in, so they count too.
var externalDisplayConnected: Bool { var externalDisplayConnected: Bool {
NSScreen.screens.count > 1 var count: UInt32 = 0
guard CGGetOnlineDisplayList(0, nil, &count) == .success, count > 0 else {
return NSScreen.screens.count > 1
}
var displays = [CGDirectDisplayID](repeating: 0, count: Int(count))
guard CGGetOnlineDisplayList(count, &displays, &count) == .success else {
return NSScreen.screens.count > 1
}
return displays.prefix(Int(count)).contains { CGDisplayIsBuiltin($0) == 0 }
} }
func start() { func start() {
+2 -1
View File
@@ -52,7 +52,8 @@ final class Preferences {
} }
} }
/// Duration used by the menu bar toggle and the global shortcut. /// Duration used by the menu bar toggle and the global shortcut when they
/// start a manual session (not when they resume paused Smart Activation).
/// 0 means indefinite. Not exposed in the menu; set it with `defaults write`. /// 0 means indefinite. Not exposed in the menu; set it with `defaults write`.
var defaultDurationMinutes: Int { var defaultDurationMinutes: Int {
get { store.integer(forKey: Key.defaultDurationMinutes) } get { store.integer(forKey: Key.defaultDurationMinutes) }
+128 -40
View File
@@ -9,6 +9,30 @@ final class WakeController {
case automatic case automatic
} }
/// The smart-activation rules, in the order their reasons are reported.
enum SmartRule {
case onAC
case externalDisplay
case battery
var name: String {
switch self {
case .onAC: return "AC rule"
case .externalDisplay: return "display rule"
case .battery: return "battery rule"
}
}
}
/// What the primary toggle (menu item and global shortcut) does right now.
/// The menu stores this on the item, so a click does what the label said
/// even if the state changed while the menu was open.
enum ToggleAction: Int {
case turnOff
case turnOn
case resumeAutomatic
}
/// Called on the main thread whenever anything the menu displays changes. /// Called on the main thread whenever anything the menu displays changes.
var onChange: (() -> Void)? var onChange: (() -> Void)?
@@ -21,11 +45,11 @@ final class WakeController {
private(set) var expiry: Date? private(set) var expiry: Date?
private(set) var automaticReason: String? private(set) var automaticReason: String?
/// Set when the user manually turns Wakeful off while a smart rule wanted it /// Rules the user has overridden, by turning Wakeful off or starting a
/// on. Without this, unplugging is the only way to make the app stop /// manual session while they held. An overridden rule can't switch Wakeful
/// re-enabling itself, which feels broken. Cleared as soon as the rules stop /// on. Each one re-arms as soon as it stops being satisfied, so unplugging
/// being satisfied. /// and replugging re-arms the AC rule even while another rule still holds.
private var suppressAutomatic = false private var suppressedRules: Set<SmartRule> = []
private var ticker: Timer? private var ticker: Timer?
@@ -44,8 +68,28 @@ final class WakeController {
var lastError: String? { blocker.lastError } var lastError: String? { blocker.lastError }
/// True while Wakeful is off only because the user overrode a rule that
/// still holds. Without saying so, a ticked rule whose condition is met
/// would look broken.
var isAutomaticPaused: Bool {
!isOn && satisfiedRules().contains { suppressedRules.contains($0.rule) }
}
var toggleAction: ToggleAction {
if isOn { return .turnOff }
return isAutomaticPaused ? .resumeAutomatic : .turnOn
}
var statusText: String { var statusText: String {
guard isOn else { guard isOn else {
if isAutomaticPaused {
// Name what is paused: other enabled rules stay armed and can
// still switch Wakeful on.
let paused = satisfiedRules()
.filter { suppressedRules.contains($0.rule) }
.map { $0.rule.name }
return "Sleep allowed — \(paused.joined(separator: " and ")) paused"
}
return prefs.anySmartRuleEnabled ? "Sleep allowed — watching conditions" return prefs.anySmartRuleEnabled ? "Sleep allowed — watching conditions"
: "Sleep allowed" : "Sleep allowed"
} }
@@ -60,11 +104,27 @@ final class WakeController {
// MARK: - Intent // MARK: - Intent
/// Used by the global shortcut, which has no label to honour, so it acts
/// on the live state.
func toggle() { func toggle() {
if isOn { perform(toggleAction)
}
/// Each case is a no-op if the state has already moved on, rather than
/// doing the opposite of what was asked.
func perform(_ action: ToggleAction) {
switch action {
case .turnOff:
guard isOn else { return }
turnOff(source: .manual) turnOff(source: .manual)
} else { case .turnOn:
guard !isOn else { return }
turnOn(minutes: prefs.defaultDurationMinutes, source: .manual) turnOn(minutes: prefs.defaultDurationMinutes, source: .manual)
case .resumeAutomatic:
guard !isOn else { return }
suppressedRules.removeAll()
evaluateAutomatic()
onChange?()
} }
} }
@@ -84,7 +144,11 @@ final class WakeController {
self.source = source self.source = source
automaticReason = reason automaticReason = reason
if source == .manual { if source == .manual {
suppressAutomatic = false // A manual session overrides whatever rules hold right now.
// evaluateAutomatic re-arms each one that stops holding, so when a
// timed session ends only rules that held throughout are still
// overridden; a rule that became true meanwhile takes over.
suppressedRules = satisfiedRuleSet()
} }
setExpiry(minutes > 0 ? Date().addingTimeInterval(TimeInterval(minutes) * 60) : nil) setExpiry(minutes > 0 ? Date().addingTimeInterval(TimeInterval(minutes) * 60) : nil)
onChange?() onChange?()
@@ -99,15 +163,13 @@ final class WakeController {
} }
func turnOff(source: Source) { func turnOff(source: Source) {
if source == .manual, isOn, automaticRuleSatisfied().satisfied { if source == .manual, isOn {
suppressAutomatic = true // Turning off by hand overrides every rule that holds right now, so
// the app doesn't immediately switch itself back on.
suppressedRules = satisfiedRuleSet()
} }
blocker.releaseAll()
isOn = false
self.source = source self.source = source
automaticReason = nil endSession()
setExpiry(nil)
onChange?()
} }
/// Re-applies the current assertion set. Called after the user changes the /// Re-applies the current assertion set. Called after the user changes the
@@ -122,16 +184,29 @@ final class WakeController {
onChange?() onChange?()
} }
private func endSession(notify: Bool = true) {
blocker.releaseAll()
isOn = false
automaticReason = nil
setExpiry(nil)
if notify {
onChange?()
}
}
// MARK: - Smart activation // MARK: - Smart activation
func automaticRuleSatisfied() -> (satisfied: Bool, reason: String?) { /// Enabled rules whose condition holds right now, each with the reason the
guard let power, prefs.anySmartRuleEnabled else { return (false, nil) } /// status line shows.
func satisfiedRules() -> [(rule: SmartRule, reason: String)] {
guard let power, prefs.anySmartRuleEnabled else { return [] }
var rules: [(rule: SmartRule, reason: String)] = []
if prefs.smartOnAC, power.isOnAC { if prefs.smartOnAC, power.isOnAC {
return (true, "plugged in") rules.append((.onAC, "plugged in"))
} }
if prefs.smartOnExternalDisplay, power.externalDisplayConnected { if prefs.smartOnExternalDisplay, power.externalDisplayConnected {
return (true, "external display connected") rules.append((.externalDisplay, "external display connected"))
} }
// Gated on !isOnAC deliberately: the point of this rule is "stay awake // Gated on !isOnAC deliberately: the point of this rule is "stay awake
// while there's charge to spare". Without the gate, a plugged-in laptop // while there's charge to spare". Without the gate, a plugged-in laptop
@@ -139,36 +214,42 @@ final class WakeController {
let threshold = prefs.smartBatteryThreshold let threshold = prefs.smartBatteryThreshold
if threshold > 0, !power.isOnAC, if threshold > 0, !power.isOnAC,
let percent = power.batteryPercent, percent >= threshold { let percent = power.batteryPercent, percent >= threshold {
return (true, "battery at \(percent)%") rules.append((.battery, "battery at \(percent)%"))
} }
return (false, nil) return rules
}
private func satisfiedRuleSet() -> Set<SmartRule> {
Set(satisfiedRules().map { $0.rule })
} }
/// Called on launch and on every power / display change. /// Called on launch and on every power / display change.
func evaluateAutomatic() { func evaluateAutomatic() {
let rule = automaticRuleSatisfied() let satisfied = satisfiedRules()
let suppressedBefore = suppressedRules
if !rule.satisfied { // Re-arm every overridden rule that no longer holds.
suppressAutomatic = false suppressedRules.formIntersection(satisfied.map { $0.rule })
} let active = satisfied.first { !suppressedRules.contains($0.rule) }
if rule.satisfied, !isOn, !suppressAutomatic { if let active, !isOn {
turnOn(minutes: 0, source: .automatic, reason: rule.reason) turnOn(minutes: 0, source: .automatic, reason: active.reason)
} else if !rule.satisfied, isOn, source == .automatic { } else if active == nil, isOn, source == .automatic {
turnOff(source: .automatic) turnOff(source: .automatic)
} else if rule.satisfied, isOn, source == .automatic, } else if let active, isOn, source == .automatic,
automaticReason != rule.reason { automaticReason != active.reason {
automaticReason = rule.reason automaticReason = active.reason
onChange?()
} else if suppressedRules != suppressedBefore {
// A rule re-armed without switching anything on or off, which still
// changes the paused status and what the toggle does.
onChange?() onChange?()
} }
} }
/// Called when the smart rules themselves are edited. /// Called when the smart rules themselves are edited. Disabling a rule also
/// re-arms it, since a disabled rule never holds.
func smartRulesChanged() { func smartRulesChanged() {
if !prefs.anySmartRuleEnabled, isOn, source == .automatic {
turnOff(source: .automatic)
return
}
evaluateAutomatic() evaluateAutomatic()
onChange?() onChange?()
} }
@@ -191,11 +272,18 @@ final class WakeController {
private func tick() { private func tick() {
guard let expiry else { return } guard let expiry else { return }
if Date() >= expiry { if Date() >= expiry {
// Pass the *current* source through: an expiring manual session must // End without turnOff(source: .manual), which would override every
// arm suppressAutomatic, or a satisfied smart rule would flip it // rule that holds now. suppressedRules already holds exactly the
// straight back on and "Keep Awake For 15 minutes" would never end. // rules that were true when the session started and stayed true:
turnOff(source: source) // those remain overridden, so "Keep Awake For 15 minutes" really
// ends on AC, while a rule that became true during the session
// takes over here. Nothing is notified until the transition is
// complete (evaluateAutomatic may notify too), so when a rule takes
// over the menu never sees the momentary "off" and the toggle
// doesn't appear to change meaning.
endSession(notify: false)
evaluateAutomatic() evaluateAutomatic()
onChange?()
} else { } else {
onChange?() onChange?()
} }