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
+124 -23
View File
@@ -10,8 +10,33 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
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 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.
private let durations: [(String, Int)] = [
@@ -80,18 +105,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
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
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
func menuNeedsUpdate(_ menu: NSMenu) {
rebuild(menu)
if menu === shortcutMenu {
markSystemShortcuts(in: menu)
} else {
rebuild(menu)
}
}
private func rebuild(_ menu: NSMenu) {
menu.removeAllItems()
toggleChangedAt = -.infinity
// Status line
let status = NSMenuItem(title: controller.statusText, action: nil, keyEquivalent: "")
@@ -108,17 +155,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.addItem(.separator())
// Primary toggle
let toggle = item(
controller.isOn ? "Turn Off" : "Turn On",
#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))"
}
let toggle = item("", #selector(toggleNow(_:)))
configureToggle(toggle)
menu.addItem(toggle)
toggleItem = toggle
// Timed sessions
let durationMenu = NSMenu()
@@ -128,10 +168,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
entry.state = (minutes == prefs.defaultDurationMinutes) ? .on : .off
durationMenu.addItem(entry)
}
if controller.remaining != nil {
durationMenu.addItem(.separator())
durationMenu.addItem(item("Add 30 minutes", #selector(extendSession(_:)), tag: 30))
}
// Always present, hidden outside a timed session. While the menu is
// open, refreshStatusItem reveals it if a session starts and only
// 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(.separator())
@@ -180,13 +229,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
smartMenu.addItem(submenu("While battery is…", batteryMenu))
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
if power.hasBattery {
let charge = power.batteryPercent.map { "\($0)%" } ?? "unknown"
conditions = "Now: \(power.isOnAC ? "AC" : "battery"), \(charge), "
+ "\(NSScreen.screens.count) display(s)"
conditions = "Now: \(power.isOnAC ? "AC" : "battery"), \(charge), \(display)"
} 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: "")
conditionsItem.isEnabled = false
@@ -199,6 +250,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
// Global shortcut
let shortcutMenu = NSMenu()
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)
noneItem.state = prefs.hotKeyIndex < 0 ? .on : .off
shortcutMenu.addItem(noneItem)
@@ -206,12 +260,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let selected = prefs.hotKeyIndex == index
let entry = item(preset.label, #selector(setHotKey(_:)), tag: index)
entry.state = selected ? .on : .off
if selected, !hotKey.isRegistered {
entry.title += " — unavailable (already in use)"
if selected, let reason = hotKey.unavailableReason {
entry.title += " — unavailable (\(reason))"
}
shortcutMenu.addItem(entry)
}
menu.addItem(submenu("Global Shortcut", shortcutMenu))
self.shortcutMenu = shortcutMenu
// Startup and display options
let launchState = LaunchAtLogin.state
@@ -255,10 +310,52 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
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
@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) {
@@ -344,12 +441,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private func applyHotKeyPreference() {
hotKey.unregister()
activeHotKeyIndex = nil
let index = prefs.hotKeyIndex
guard index >= 0, index < HotKeyPreset.all.count else { return }
let preset = HotKeyPreset.all[index]
hotKey.register(keyCode: preset.keyCode, modifiers: preset.modifiers) { [weak self] in
self?.controller.toggle()
}
if hotKey.isRegistered {
activeHotKeyIndex = index
}
}
private func presentAlert(title: String, message: String) {
+54 -7
View File
@@ -27,10 +27,17 @@ struct HotKeyPreset {
/// through an Accessibility / Input Monitoring permission prompt.
/// `RegisterEventHotKey` needs no entitlement and no permission at all.
///
/// Two limits are inherent to the API: registration fails with
/// `eventHotKeyExistsErr` if another app or a System Settings shortcut already
/// owns the combination, and no hot key fires while macOS Secure Input is
/// active (i.e. while a password field has focus).
/// Limits inherent to the API:
///
/// - Registration is non-exclusive and succeeds even when another app already
/// 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 {
/// Keyed by hot key id. Stores the closure rather than the `HotKey` object so
@@ -47,13 +54,20 @@ final class HotKey {
HotKey.nextID += 1
}
/// False after a failed `register` — e.g. another app already owns the
/// combination (`eventHotKeyExistsErr`). The menu reads this so it never
/// advertises a shortcut that will not fire.
/// The menu reads this so it never advertises a shortcut that isn't live.
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) {
unregister()
if HotKey.isSystemShortcut(keyCode: keyCode, modifiers: modifiers,
in: HotKey.systemShortcuts()) {
unavailableReason = "used by a macOS shortcut"
return
}
HotKey.handlers[identifier] = action
HotKey.installHandlerIfNeeded()
@@ -71,6 +85,7 @@ final class HotKey {
hotKeyRef = reference
} else {
HotKey.handlers[identifier] = nil
unavailableReason = "registration failed, error \(status)"
}
}
@@ -79,6 +94,7 @@ final class HotKey {
UnregisterEventHotKey(hotKeyRef)
}
hotKeyRef = nil
unavailableReason = nil
HotKey.handlers[identifier] = nil
}
@@ -86,6 +102,37 @@ final class HotKey {
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
/// '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.
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 {
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() {
+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`.
var defaultDurationMinutes: Int {
get { store.integer(forKey: Key.defaultDurationMinutes) }
+128 -40
View File
@@ -9,6 +9,30 @@ final class WakeController {
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.
var onChange: (() -> Void)?
@@ -21,11 +45,11 @@ final class WakeController {
private(set) var expiry: Date?
private(set) var automaticReason: String?
/// Set when the user manually turns Wakeful off while a smart rule wanted it
/// on. Without this, unplugging is the only way to make the app stop
/// re-enabling itself, which feels broken. Cleared as soon as the rules stop
/// being satisfied.
private var suppressAutomatic = false
/// Rules the user has overridden, by turning Wakeful off or starting a
/// manual session while they held. An overridden rule can't switch Wakeful
/// on. Each one re-arms as soon as it stops being satisfied, so unplugging
/// and replugging re-arms the AC rule even while another rule still holds.
private var suppressedRules: Set<SmartRule> = []
private var ticker: Timer?
@@ -44,8 +68,28 @@ final class WakeController {
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 {
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"
: "Sleep allowed"
}
@@ -60,11 +104,27 @@ final class WakeController {
// MARK: - Intent
/// Used by the global shortcut, which has no label to honour, so it acts
/// on the live state.
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)
} else {
case .turnOn:
guard !isOn else { return }
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
automaticReason = reason
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)
onChange?()
@@ -99,15 +163,13 @@ final class WakeController {
}
func turnOff(source: Source) {
if source == .manual, isOn, automaticRuleSatisfied().satisfied {
suppressAutomatic = true
if source == .manual, isOn {
// 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
automaticReason = nil
setExpiry(nil)
onChange?()
endSession()
}
/// Re-applies the current assertion set. Called after the user changes the
@@ -122,16 +184,29 @@ final class WakeController {
onChange?()
}
private func endSession(notify: Bool = true) {
blocker.releaseAll()
isOn = false
automaticReason = nil
setExpiry(nil)
if notify {
onChange?()
}
}
// MARK: - Smart activation
func automaticRuleSatisfied() -> (satisfied: Bool, reason: String?) {
guard let power, prefs.anySmartRuleEnabled else { return (false, nil) }
/// Enabled rules whose condition holds right now, each with the reason the
/// 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 {
return (true, "plugged in")
rules.append((.onAC, "plugged in"))
}
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
// while there's charge to spare". Without the gate, a plugged-in laptop
@@ -139,36 +214,42 @@ final class WakeController {
let threshold = prefs.smartBatteryThreshold
if threshold > 0, !power.isOnAC,
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.
func evaluateAutomatic() {
let rule = automaticRuleSatisfied()
let satisfied = satisfiedRules()
let suppressedBefore = suppressedRules
if !rule.satisfied {
suppressAutomatic = false
}
// Re-arm every overridden rule that no longer holds.
suppressedRules.formIntersection(satisfied.map { $0.rule })
let active = satisfied.first { !suppressedRules.contains($0.rule) }
if rule.satisfied, !isOn, !suppressAutomatic {
turnOn(minutes: 0, source: .automatic, reason: rule.reason)
} else if !rule.satisfied, isOn, source == .automatic {
if let active, !isOn {
turnOn(minutes: 0, source: .automatic, reason: active.reason)
} else if active == nil, isOn, source == .automatic {
turnOff(source: .automatic)
} else if rule.satisfied, isOn, source == .automatic,
automaticReason != rule.reason {
automaticReason = rule.reason
} else if let active, isOn, source == .automatic,
automaticReason != active.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?()
}
}
/// 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() {
if !prefs.anySmartRuleEnabled, isOn, source == .automatic {
turnOff(source: .automatic)
return
}
evaluateAutomatic()
onChange?()
}
@@ -191,11 +272,18 @@ final class WakeController {
private func tick() {
guard let expiry else { return }
if Date() >= expiry {
// Pass the *current* source through: an expiring manual session must
// arm suppressAutomatic, or a satisfied smart rule would flip it
// straight back on and "Keep Awake For 15 minutes" would never end.
turnOff(source: source)
// End without turnOff(source: .manual), which would override every
// rule that holds now. suppressedRules already holds exactly the
// rules that were true when the session started and stayed true:
// 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()
onChange?()
} else {
onChange?()
}