Merge pull request 'Release 1.1.0: fix display rule, shortcut clashes, stale menu and smart activation' (#1) from release/1.1.0 into main
Reviewed-on: hits/Wakeful#1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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,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 ?= -
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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?()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user