5 Commits
Author SHA1 Message Date
aibot 785bbc4326 Merge pull request 'Ci/basic checks' (#2) from ci/basic-checks into main
check / lint (push) Successful in 6m26s
Reviewed-on: hits/Wakeful#2
2026-09-28 05:31:21 +00:00
Gary HansenandClaude Opus 5.5 4e52208962 Move macOS build to a manual-only workflow
check / lint (push) Waiting to run
Gitea evaluates a job's `if` on the runner, so with no macOS runner the
gated job sat queued forever and kept every run in progress. Make it a
separate workflow_dispatch-only workflow until a macOS runner exists.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:21:54 +10:00
Gary HansenandClaude Opus 5.5 757f1ed64a Add Gitea Actions pipeline with basic checks
check / lint (push) Successful in 7m16s
check / macos (push) Canceled after 0s
scripts/check.sh (also `make check`) parses the Swift sources, validates
Info.plist and its placeholders, checks the Makefile VERSION against the
newest CHANGELOG release, and flags whitespace errors and conflict markers.

The lint job runs it on the existing Linux runners in a swift:6.3
container. A full macOS build job is included but skipped until a macOS
runner is registered and the MACOS_RUNNER repo variable is set to true.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 15:14:13 +10:00
gary e184197073 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
2026-09-23 08:29:01 +00:00
Gary HansenandClaude Opus 5.5 ee2e22b2f7 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>
2026-09-23 18:08:35 +10:00
12 changed files with 540 additions and 91 deletions
Binary file not shown.
+29
View File
@@ -0,0 +1,29 @@
name: check
on:
push:
workflow_dispatch:
jobs:
# Runs on the existing Linux runners. Wakeful links AppKit, IOKit and Carbon,
# so this can parse the Swift but not compile it; see macos.yaml.
lint:
runs-on: ubuntu-latest
container:
image: swift:6.3
steps:
# actions/checkout needs Node, which the Swift image doesn't have.
- name: Checkout
env:
TOKEN: ${{ gitea.token }}
run: |
git config --global --add safe.directory "$PWD"
git init -q .
auth=$(printf 'x-access-token:%s' "$TOKEN" | base64 | tr -d '\n')
git -c http.extraHeader="Authorization: Basic $auth" \
fetch -q --depth=1 "${{ gitea.server_url }}/${{ gitea.repository }}.git" "${{ gitea.sha }}"
git checkout -q FETCH_HEAD
git log -1 --oneline
- name: Check
run: scripts/check.sh
+24
View File
@@ -0,0 +1,24 @@
name: macos
# A real build. Manual-only for now: there is no macOS runner, and Gitea leaves
# a job queued (not skipped) when no runner has its label, even behind an `if`.
# Once an act_runner with the "macos" label is registered, add `push:` here.
on:
workflow_dispatch:
jobs:
build:
runs-on: macos
steps:
- uses: actions/checkout@v4
- name: Check
run: scripts/check.sh
- name: Build (warnings are errors)
run: swift build -c release -Xswiftc -warnings-as-errors
- name: Bundle and ad-hoc sign
run: |
make bundle
plutil -lint dist/Wakeful.app/Contents/Info.plist
+60
View File
@@ -0,0 +1,60 @@
# Changelog
All notable changes to Wakeful are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
[Semantic Versioning](https://semver.org/).
## [1.1.0] - 2026-09-23
### Fixed
- **External display rule works where it matters.** It now looks for any
connected display that isn't the built-in panel, instead of counting screens,
so it fires with the lid closed (clamshell), when mirroring to a projector,
and on a desktop Mac's only monitor. Docking and closing the lid no longer
switches Wakeful off. The Smart Activation footer shows the same check
("external display" / "no external display") instead of a screen count.
- **Global shortcut clashes with macOS shortcuts are detected.** Presets that
clash with a system shortcut (screenshots, Spotlight, Mission Control, input
sources…) are marked "used by a macOS shortcut" and can't be picked, and a
failed registration shows its real reason. 1.0.0 promised an "unavailable"
warning that could never appear. Clashes with App Shortcuts, Services and
other apps can't be detected by any public API; the README now says so.
- **An open menu no longer goes stale.** The status line, Turn On / Turn Off
and "Add 30 minutes" update while the menu is open, and the toggle always
does what its label said. Previously, clicking "Turn Off" as a timed session
expired started an indefinite session. A click that lands just as the label
changes is now ignored.
- **Smart Activation after a manual override:**
- Turning Wakeful off while a rule applies now says what is paused
("Sleep allowed — AC rule paused") instead of "watching conditions".
- Each paused rule re-arms as soon as its condition stops holding, so
unplugging and replugging re-arms the AC rule even with other rules on.
- A rule that becomes true during a timed session (you dock halfway through)
takes over when the session ends. A rule that held throughout stays paused,
so "Keep Awake For 15 minutes" still really ends on AC.
- Turning off and back on no longer converts an automatic session into an
indefinite manual one that survives unplugging.
- The shortcut hint on Turn On / Turn Off (e.g. "(⌃⌥⌘K)") only appears when
that shortcut is actually registered.
### Changed
- While Smart Activation is paused, Turn On and the global shortcut become
**Resume Smart Activation**, handing control back to the rules. In that state
they don't use `defaultDurationMinutes`; use Keep Awake For to start a manual
session.
- Turn Off overrides only the rules that hold at that moment. With the AC and
battery rules both on, turning off at your desk and then unplugging lets the
battery rule switch Wakeful back on; 1.0.0 stayed off.
- A deliberate click on the toggle within about half a second of its label
changing is ignored and needs repeating.
- The chosen shortcut preset is checked for system-shortcut clashes when you
pick it and at each launch; a clash created later is noticed at the next
launch.
- README: smart activation semantics, the menu design notes and the global
shortcut limitations rewritten to match the code.
## [1.0.0]
- Initial release.
+6 -2
View File
@@ -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 ?= -
@@ -15,7 +15,7 @@ CONTENTS := $(APP)/Contents
# A secure timestamp is required for notarization but impossible for ad-hoc.
TS := $(if $(filter -,$(SIGN_ID)),--timestamp=none,--timestamp)
.PHONY: all build universal app app-universal bundle sign install uninstall run clean
.PHONY: all build universal app app-universal bundle sign install uninstall run check clean
all: app
@@ -73,6 +73,10 @@ uninstall:
run: build
"$(BUILD_DIR)/$(APP_NAME)"
## The same basic checks CI runs (syntax, Info.plist, version, whitespace).
check:
scripts/check.sh
clean:
swift package clean
rm -rf .build "$(DIST)"
+45 -17
View File
@@ -72,8 +72,13 @@ Two design decisions worth calling out:
**The menu is rebuilt from scratch in `menuNeedsUpdate(_:)`.** Menu bar apps
accumulate bugs from trying to keep dozens of checkmarks in sync with model
state. Throwing the menu away and regenerating it from `Preferences` and
`WakeController` on each open makes desync structurally impossible, and a menu
opens rarely enough that the cost is irrelevant.
`WakeController` on each open prevents desync between openings, and a menu
opens rarely enough that the cost is irrelevant. State can still change while
the menu is open (a timed session expires, you plug in), so the few items whose
meaning depends on it — the status line, the Turn On / Turn Off toggle and
"Add 30 minutes" — are refreshed live, and the toggle carries the action it
displays. A click never does the opposite of its label: if the toggle changed
meaning just as you clicked, the click is ignored.
**`SleepBlocker.apply(_:)` is declarative, not imperative.** You hand it the set
of assertions you want held and it reconciles the delta. No `if held { release }`
@@ -82,14 +87,27 @@ branching at call sites, and repeated calls are free.
### Smart activation semantics
The tricky part of conditional activation is not the conditions, it's not
fighting the user. Wakeful tracks *why* it is on (`.manual` vs `.automatic`):
fighting the user. Wakeful tracks *why* it is on (`.manual` vs `.automatic`),
and which rules you have overridden:
- A rule becoming true turns Wakeful on with source `.automatic`.
- A rule becoming false turns it off again **only if** source is `.automatic`.
Your manual session is never cancelled by a rule.
- Manually turning it **off** while a rule wants it on sets a suppression flag,
so the app doesn't immediately re-enable itself. The flag clears as soon as
the rule stops being satisfied — so unplugging and replugging re-arms it.
- When the last rule you haven't overridden stops holding, it turns off again
**only if** source is `.automatic`. Your manual session is never cancelled by
a rule.
- Manually turning it **off** while a rule wants it on overrides that rule, so
the app doesn't immediately re-enable itself. The status line names what is
paused (e.g. "Sleep allowed — AC rule paused"), and Turn On (and the global
shortcut) becomes **Resume Smart Activation**, which hands control back to the
rules. Only rules that hold at that moment are overridden; the others stay
armed. With the AC and battery rules both on, turning off at your desk and
then unplugging lets the battery rule switch Wakeful back on.
- Each overridden rule re-arms on its own as soon as it stops being satisfied —
so unplugging and replugging re-arms the AC rule even while an external
display keeps another rule true. Enabling a rule arms it too.
- Starting a manual session overrides the rules that hold at that moment. When
a timed session ends, a rule that held throughout stays overridden — so "Keep
Awake For 15 minutes" really ends, even on AC — but a rule that became true
during the session (you docked halfway through) takes over.
### Global shortcut
@@ -124,6 +142,7 @@ Other targets:
| `make app` | Build and sign into `./dist/Wakeful.app` without installing |
| `make app-universal` | Same, from a universal arm64 + x86_64 binary |
| `make run` | Run the raw binary in the terminal (for debugging) |
| `make check` | Run the basic checks CI runs on every push |
| `make uninstall` | Quit, remove from `/Applications`, delete preferences |
| `make clean` | Remove build artifacts |
@@ -143,26 +162,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
+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?()
}
+51
View File
@@ -0,0 +1,51 @@
#!/bin/sh
# Basic checks that need no macOS SDK, so CI can run them on Linux.
# Run locally with `make check`. A full compile still needs a Mac.
set -eu
cd "$(dirname "$0")/.."
fail=0
step() { printf '\n==> %s\n' "$1"; }
bad() { printf 'FAIL: %s\n' "$1"; fail=1; }
step "Swift syntax"
swiftc -parse Sources/Wakeful/*.swift || bad "swiftc -parse reported errors"
step "Info.plist"
# The Makefile seds these placeholders in; losing one ships a broken bundle.
for key in __BUNDLE_ID__ __VERSION__; do
grep -q "$key" Resources/Info.plist || bad "Resources/Info.plist is missing $key"
done
if command -v plutil >/dev/null 2>&1; then
plutil -lint Resources/Info.plist || bad "Resources/Info.plist is not a valid plist"
else
swift - <<'SWIFT' || bad "Resources/Info.plist is not a valid plist"
import Foundation
let data = try Data(contentsOf: URL(fileURLWithPath: "Resources/Info.plist"))
_ = try PropertyListSerialization.propertyList(from: data, format: nil)
print("Resources/Info.plist: OK")
SWIFT
fi
step "Version"
version=$(sed -n 's/^VERSION *:= *//p' Makefile)
latest=$(sed -n 's/^## \[\([0-9][^]]*\)\].*/\1/p' CHANGELOG.md | head -n 1)
if [ -z "$version" ]; then
bad "no VERSION in Makefile"
elif [ "$version" != "$latest" ]; then
bad "Makefile VERSION is $version but the newest CHANGELOG release is ${latest:-missing}"
else
echo "$version"
fi
step "Whitespace and conflict markers"
# Diff the whole tree against the empty tree so this works on a shallow clone.
empty=$(git hash-object -t tree /dev/null)
git diff --check "$empty" HEAD -- . && echo OK || bad "whitespace errors or conflict markers (see above)"
echo
if [ "$fail" -ne 0 ]; then
echo "Checks failed."
exit 1
fi
echo "All checks passed."