From ee2e22b2f785712cba03fe4431c4f86c8d20fd54 Mon Sep 17 00:00:00 2001 From: Gary Hansen Date: Wed, 23 Sep 2026 17:49:26 +1000 Subject: [PATCH] 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) --- CHANGELOG.md | 60 ++++++++++ Makefile | 2 +- README.md | 61 +++++++--- Sources/Wakeful/AppDelegate.swift | 147 +++++++++++++++++++---- Sources/Wakeful/HotKey.swift | 61 ++++++++-- Sources/Wakeful/PowerMonitor.swift | 18 ++- Sources/Wakeful/Preferences.swift | 3 +- Sources/Wakeful/WakeController.swift | 168 ++++++++++++++++++++------- 8 files changed, 430 insertions(+), 90 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bd850dd --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/Makefile b/Makefile index 7481330..228e97c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ APP_NAME := 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)" SIGN_ID ?= - diff --git a/README.md b/README.md index fafe065..c9ed48d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/Sources/Wakeful/AppDelegate.swift b/Sources/Wakeful/AppDelegate.swift index 83530e6..67c47e5 100644 --- a/Sources/Wakeful/AppDelegate.swift +++ b/Sources/Wakeful/AppDelegate.swift @@ -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) { diff --git a/Sources/Wakeful/HotKey.swift b/Sources/Wakeful/HotKey.swift index 858bf82..2f444cb 100644 --- a/Sources/Wakeful/HotKey.swift +++ b/Sources/Wakeful/HotKey.swift @@ -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? + 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' diff --git a/Sources/Wakeful/PowerMonitor.swift b/Sources/Wakeful/PowerMonitor.swift index 6ff9254..b587f06 100644 --- a/Sources/Wakeful/PowerMonitor.swift +++ b/Sources/Wakeful/PowerMonitor.swift @@ -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() { diff --git a/Sources/Wakeful/Preferences.swift b/Sources/Wakeful/Preferences.swift index 41bdc06..ed09cd7 100644 --- a/Sources/Wakeful/Preferences.swift +++ b/Sources/Wakeful/Preferences.swift @@ -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) } diff --git a/Sources/Wakeful/WakeController.swift b/Sources/Wakeful/WakeController.swift index 0de504d..b33f612 100644 --- a/Sources/Wakeful/WakeController.swift +++ b/Sources/Wakeful/WakeController.swift @@ -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 = [] 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 { + 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?() }