initial commit

This commit is contained in:
Gary Hansen
2026-09-15 12:44:18 +10:00
commit 2363fc2165
15 changed files with 1503 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let controller = WakeController()
private let power = PowerMonitor()
private let hotKey = HotKey()
private let prefs = Preferences.shared
private var statusItem: NSStatusItem!
private let menu = NSMenu()
/// Held so the countdown in the status line stays live while the menu is open.
private weak var statusLineItem: NSMenuItem?
/// Durations offered in the "Keep awake for" submenu, in minutes.
private let durations: [(String, Int)] = [
("15 minutes", 15),
("30 minutes", 30),
("1 hour", 60),
("2 hours", 120),
("5 hours", 300),
("Until turned off", 0)
]
private let batteryThresholds = [20, 50, 80]
// MARK: - Lifecycle
func applicationDidFinishLaunching(_ notification: Notification) {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
statusItem.button?.toolTip = "Wakeful"
menu.delegate = self
menu.autoenablesItems = false
statusItem.menu = menu
controller.attach(power: power)
controller.onChange = { [weak self] in self?.refreshStatusItem() }
power.onChange = { [weak self] in self?.controller.evaluateAutomatic() }
power.start()
applyHotKeyPreference()
if prefs.activateOnLaunch {
controller.turnOn(minutes: prefs.defaultDurationMinutes, source: .manual)
}
controller.evaluateAutomatic()
refreshStatusItem()
}
func applicationWillTerminate(_ notification: Notification) {
controller.turnOff(source: .manual)
hotKey.unregister()
power.stop()
}
func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool {
true
}
// MARK: - Status item
private func refreshStatusItem() {
guard let button = statusItem.button else { return }
let symbol = controller.isOn ? "cup.and.saucer.fill" : "cup.and.saucer"
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Wakeful")
image?.isTemplate = true
button.image = image
if prefs.showCountdown, let remaining = controller.remaining {
button.title = " " + WakeController.format(remaining)
button.imagePosition = .imageLeading
} else {
button.title = ""
button.imagePosition = .imageOnly
}
button.toolTip = "Wakeful — " + controller.statusText
// Keep an already-open menu's status line ticking too.
statusLineItem?.title = controller.statusText
}
// MARK: - Menu construction
func menuNeedsUpdate(_ menu: NSMenu) {
rebuild(menu)
}
private func rebuild(_ menu: NSMenu) {
menu.removeAllItems()
// Status line
let status = NSMenuItem(title: controller.statusText, action: nil, keyEquivalent: "")
status.isEnabled = false
menu.addItem(status)
statusLineItem = status
if let error = controller.lastError {
let item = NSMenuItem(title: "⚠︎ " + error, action: nil, keyEquivalent: "")
item.isEnabled = false
menu.addItem(item)
}
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))"
}
menu.addItem(toggle)
// Timed sessions
let durationMenu = NSMenu()
durationMenu.autoenablesItems = false
for (label, minutes) in durations {
let entry = item(label, #selector(startSession(_:)), tag: minutes)
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))
}
menu.addItem(submenu("Keep Awake For", durationMenu))
menu.addItem(.separator())
// Assertion selection
let preventMenu = NSMenu()
preventMenu.autoenablesItems = false
for kind in AssertionKind.displayOrder {
let entry = item(kind.title, #selector(toggleAssertion(_:)))
entry.representedObject = kind.rawValue
// While a session is live, show what is *actually* held, so a failed
// assertion doesn't sit there with a reassuring checkmark.
let checked = controller.isOn ? controller.heldKinds.contains(kind)
: prefs.assertions.contains(kind)
entry.state = checked ? .on : .off
preventMenu.addItem(entry)
}
preventMenu.addItem(.separator())
menu.addItem(submenu("Prevent", preventMenu))
// Smart activation
let smartMenu = NSMenu()
smartMenu.autoenablesItems = false
let acItem = item("While plugged in to AC", #selector(toggleSmartAC(_:)))
acItem.state = prefs.smartOnAC ? .on : .off
smartMenu.addItem(acItem)
let displayItem = item("While an external display is connected",
#selector(toggleSmartDisplay(_:)))
displayItem.state = prefs.smartOnExternalDisplay ? .on : .off
smartMenu.addItem(displayItem)
let batteryMenu = NSMenu()
batteryMenu.autoenablesItems = false
let offItem = item("Off", #selector(setBatteryThreshold(_:)), tag: 0)
offItem.state = prefs.smartBatteryThreshold == 0 ? .on : .off
batteryMenu.addItem(offItem)
for threshold in batteryThresholds {
let entry = item("Above \(threshold)%", #selector(setBatteryThreshold(_:)),
tag: threshold)
entry.state = prefs.smartBatteryThreshold == threshold ? .on : .off
entry.isEnabled = power.hasBattery
batteryMenu.addItem(entry)
}
smartMenu.addItem(submenu("While battery is…", batteryMenu))
smartMenu.addItem(.separator())
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)"
} else {
conditions = "Now: AC (no battery), \(NSScreen.screens.count) display(s)"
}
let conditionsItem = NSMenuItem(title: conditions, action: nil, keyEquivalent: "")
conditionsItem.isEnabled = false
smartMenu.addItem(conditionsItem)
menu.addItem(submenu("Smart Activation", smartMenu))
menu.addItem(.separator())
// Global shortcut
let shortcutMenu = NSMenu()
shortcutMenu.autoenablesItems = false
let noneItem = item("Off", #selector(setHotKey(_:)), tag: -1)
noneItem.state = prefs.hotKeyIndex < 0 ? .on : .off
shortcutMenu.addItem(noneItem)
for (index, preset) in HotKeyPreset.all.enumerated() {
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)"
}
shortcutMenu.addItem(entry)
}
menu.addItem(submenu("Global Shortcut", shortcutMenu))
// Startup and display options
let launchState = LaunchAtLogin.state
let launchItem = item("Launch at Login", #selector(toggleLaunchAtLogin(_:)))
launchItem.state = (launchState == .enabled || launchState == .requiresApproval)
? .on : .off
launchItem.isEnabled = LaunchAtLogin.isRunningFromBundle
if launchState == .requiresApproval {
launchItem.title = "Launch at Login (needs approval in System Settings)"
}
menu.addItem(launchItem)
let onLaunchItem = item("Turn On at Launch", #selector(toggleActivateOnLaunch(_:)))
onLaunchItem.state = prefs.activateOnLaunch ? .on : .off
menu.addItem(onLaunchItem)
let countdownItem = item("Show Countdown in Menu Bar", #selector(toggleCountdown(_:)))
countdownItem.state = prefs.showCountdown ? .on : .off
menu.addItem(countdownItem)
menu.addItem(.separator())
menu.addItem(item("About Wakeful", #selector(showAbout(_:))))
menu.addItem(item("Quit Wakeful", #selector(quit(_:)), keyEquivalent: "q"))
}
private func item(_ title: String,
_ action: Selector,
tag: Int = 0,
keyEquivalent: String = "") -> NSMenuItem {
let entry = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent)
entry.target = self
entry.tag = tag
entry.isEnabled = true
return entry
}
private func submenu(_ title: String, _ child: NSMenu) -> NSMenuItem {
let entry = NSMenuItem(title: title, action: nil, keyEquivalent: "")
entry.submenu = child
entry.isEnabled = true
return entry
}
// MARK: - Actions
@objc private func toggleNow(_ sender: NSMenuItem) {
controller.toggle()
}
@objc private func startSession(_ sender: NSMenuItem) {
controller.turnOn(minutes: sender.tag, source: .manual)
}
@objc private func extendSession(_ sender: NSMenuItem) {
controller.extend(by: sender.tag)
}
@objc private func toggleAssertion(_ sender: NSMenuItem) {
guard let raw = sender.representedObject as? String,
let kind = AssertionKind(rawValue: raw) else { return }
var current = prefs.assertions
if current.contains(kind) {
// Never let the user disable every assertion — that would leave the
// app switched "on" while doing nothing at all.
guard current.count > 1 else { return }
current.remove(kind)
} else {
current.insert(kind)
}
prefs.assertions = current
controller.reapplyAssertions()
}
@objc private func toggleSmartAC(_ sender: NSMenuItem) {
prefs.smartOnAC.toggle()
controller.smartRulesChanged()
}
@objc private func toggleSmartDisplay(_ sender: NSMenuItem) {
prefs.smartOnExternalDisplay.toggle()
controller.smartRulesChanged()
}
@objc private func setBatteryThreshold(_ sender: NSMenuItem) {
prefs.smartBatteryThreshold = sender.tag
controller.smartRulesChanged()
}
@objc private func setHotKey(_ sender: NSMenuItem) {
prefs.hotKeyIndex = sender.tag
applyHotKeyPreference()
refreshStatusItem()
}
@objc private func toggleLaunchAtLogin(_ sender: NSMenuItem) {
let turningOn = (sender.state != .on)
if let message = LaunchAtLogin.set(turningOn) {
presentAlert(title: "Launch at Login", message: message)
}
}
@objc private func toggleActivateOnLaunch(_ sender: NSMenuItem) {
prefs.activateOnLaunch.toggle()
}
@objc private func toggleCountdown(_ sender: NSMenuItem) {
prefs.showCountdown.toggle()
refreshStatusItem()
}
@objc private func showAbout(_ sender: NSMenuItem) {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString")
as? String ?? "dev"
presentAlert(
title: "Wakeful \(version)",
message: """
A menu bar switch for macOS sleep.
Verify what is held at any time with:
pmset -g assertions
"""
)
}
@objc private func quit(_ sender: NSMenuItem) {
NSApp.terminate(nil)
}
// MARK: - Helpers
private func applyHotKeyPreference() {
hotKey.unregister()
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()
}
}
private func presentAlert(title: String, message: String) {
if #available(macOS 14.0, *) {
NSApp.activate()
} else {
NSApp.activate(ignoringOtherApps: true)
}
let alert = NSAlert()
alert.messageText = title
alert.informativeText = message
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
}
+130
View File
@@ -0,0 +1,130 @@
import AppKit
import Carbon.HIToolbox
/// A selectable global shortcut.
///
/// v1 ships presets rather than a recorder UI — a recorder is a lot of surface
/// area for a toggle that most people set once.
struct HotKeyPreset {
let label: String
let keyCode: UInt32
let modifiers: UInt32
static let all: [HotKeyPreset] = [
HotKeyPreset(label: "⌃⌥⌘A", keyCode: UInt32(kVK_ANSI_A),
modifiers: UInt32(controlKey | optionKey | cmdKey)),
HotKeyPreset(label: "⌃⌥⌘K", keyCode: UInt32(kVK_ANSI_K),
modifiers: UInt32(controlKey | optionKey | cmdKey)),
HotKeyPreset(label: "⇧⌥⌘Z", keyCode: UInt32(kVK_ANSI_Z),
modifiers: UInt32(shiftKey | optionKey | cmdKey))
]
}
/// Registers a system-wide shortcut using the Carbon hot key API.
///
/// This deliberately does *not* use `CGEventTap` or
/// `NSEvent.addGlobalMonitorForEvents`, both of which would force the user
/// 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).
final class HotKey {
/// Keyed by hot key id. Stores the closure rather than the `HotKey` object so
/// that instances are not kept alive by the registry and `deinit` can run.
private static var handlers: [UInt32: () -> Void] = [:]
private static var nextID: UInt32 = 1
private static var handlerInstalled = false
private let identifier: UInt32
private var hotKeyRef: EventHotKeyRef?
init() {
identifier = HotKey.nextID
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.
var isRegistered: Bool { hotKeyRef != nil }
func register(keyCode: UInt32, modifiers: UInt32, action: @escaping () -> Void) {
unregister()
HotKey.handlers[identifier] = action
HotKey.installHandlerIfNeeded()
let hotKeyID = EventHotKeyID(signature: HotKey.signature, id: identifier)
var reference: EventHotKeyRef?
let status = RegisterEventHotKey(
keyCode,
modifiers,
hotKeyID,
GetEventDispatcherTarget(),
0,
&reference
)
if status == noErr {
hotKeyRef = reference
} else {
HotKey.handlers[identifier] = nil
}
}
func unregister() {
if let hotKeyRef {
UnregisterEventHotKey(hotKeyRef)
}
hotKeyRef = nil
HotKey.handlers[identifier] = nil
}
deinit {
unregister()
}
// MARK: - Carbon plumbing
/// 'WKFL'
private static let signature: OSType = 0x574B_464C
private static func installHandlerIfNeeded() {
guard !handlerInstalled else { return }
var spec = EventTypeSpec(
eventClass: OSType(kEventClassKeyboard),
eventKind: UInt32(kEventHotKeyPressed)
)
let status = InstallEventHandler(
GetEventDispatcherTarget(),
{ _, event, _ -> OSStatus in
guard let event else { return OSStatus(eventNotHandledErr) }
var hotKeyID = EventHotKeyID()
let result = GetEventParameter(
event,
EventParamName(kEventParamDirectObject),
EventParamType(typeEventHotKeyID),
nil,
MemoryLayout<EventHotKeyID>.size,
nil,
&hotKeyID
)
guard result == noErr, hotKeyID.signature == HotKey.signature else {
return OSStatus(eventNotHandledErr)
}
HotKey.handlers[hotKeyID.id]?()
return noErr
},
1,
&spec,
nil,
nil
)
handlerInstalled = (status == noErr)
}
}
+68
View File
@@ -0,0 +1,68 @@
import Foundation
import ServiceManagement
/// Launch at Login via `SMAppService` (macOS 13+).
///
/// This registers the app bundle itself as a login item — no helper target, no
/// LaunchAgent plist to maintain. Two practical caveats:
///
/// 1. The bundle must be code-signed. `make app` ad-hoc signs it, which is
/// enough for local use.
/// 2. The registration records the bundle's path, so install to /Applications
/// *before* enabling this. Moving the app afterwards breaks it.
enum LaunchAtLogin {
enum State {
case enabled
case disabled
case requiresApproval
case unavailable
}
static var state: State {
switch SMAppService.mainApp.status {
case .enabled: return .enabled
case .notRegistered: return .disabled
case .requiresApproval: return .requiresApproval
case .notFound: return .unavailable
@unknown default: return .unavailable
}
}
static var isEnabled: Bool {
state == .enabled || state == .requiresApproval
}
/// Returns nil on success, or a message suitable for an alert.
///
/// `register()` throws if already registered and `unregister()` throws if
/// not, so both are guarded — otherwise a double-tap surfaces a raw
/// "Operation not permitted" to the user.
@discardableResult
static func set(_ enabled: Bool) -> String? {
let current = SMAppService.mainApp.status
do {
if enabled {
guard current != .enabled, current != .requiresApproval else { return nil }
try SMAppService.mainApp.register()
} else {
guard current != .notRegistered, current != .notFound else { return nil }
try SMAppService.mainApp.unregister()
}
} catch {
return error.localizedDescription
}
if enabled, state == .requiresApproval {
return "macOS needs you to approve Wakeful in "
+ "System Settings ▸ General ▸ Login Items & Extensions."
}
return nil
}
/// True when running from a real bundle; login items cannot be registered
/// for a bare `swift run` binary.
static var isRunningFromBundle: Bool {
Bundle.main.bundleIdentifier != nil
}
}
+114
View File
@@ -0,0 +1,114 @@
import AppKit
import IOKit.ps
/// Watches the two things the smart-activation rules care about: power source
/// (AC vs battery, plus charge level) and whether an external display is attached.
final class PowerMonitor {
/// Called on the main thread whenever power state or screen layout changes.
var onChange: (() -> Void)?
private(set) var isOnAC: Bool = true
private(set) var batteryPercent: Int?
private var runLoopSource: CFRunLoopSource?
private var screenObserver: NSObjectProtocol?
/// Desktops and Mac minis have no battery; treat them as permanently on AC.
private(set) var hasBattery: Bool = false
var externalDisplayConnected: Bool {
NSScreen.screens.count > 1
}
func start() {
guard runLoopSource == nil else { return }
refresh()
let context = Unmanaged.passUnretained(self).toOpaque()
let callback: IOPowerSourceCallbackType = { rawContext in
guard let rawContext else { return }
let monitor = Unmanaged<PowerMonitor>.fromOpaque(rawContext).takeUnretainedValue()
monitor.refresh()
monitor.onChange?()
}
if let source = IOPSNotificationCreateRunLoopSource(callback, context)?.takeRetainedValue() {
runLoopSource = source
// .commonModes, not .defaultMode: otherwise power changes are dropped
// while a menu is tracking or an alert is up.
CFRunLoopAddSource(CFRunLoopGetMain(), source, CFRunLoopMode.commonModes)
}
screenObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.onChange?()
}
}
func stop() {
if let runLoopSource {
CFRunLoopRemoveSource(CFRunLoopGetMain(), runLoopSource, CFRunLoopMode.commonModes)
}
runLoopSource = nil
if let screenObserver {
NotificationCenter.default.removeObserver(screenObserver)
}
screenObserver = nil
}
/// Reads a fresh power-source snapshot.
///
/// AC state is derived from the internal battery's own power-source state
/// rather than `IOPSGetProvidingPowerSourceType`, which keeps the Unmanaged
/// ownership rules unambiguous.
func refresh() {
guard let snapshot = IOPSCopyPowerSourcesInfo()?.takeRetainedValue(),
let list = IOPSCopyPowerSourcesList(snapshot)?.takeRetainedValue() as NSArray?
else {
hasBattery = false
isOnAC = true
batteryPercent = nil
return
}
var foundBattery = false
var onAC = true
var percent: Int?
for element in list {
let source = element as CFTypeRef
guard let description =
IOPSGetPowerSourceDescription(snapshot, source)?.takeUnretainedValue()
as? [String: Any]
else { continue }
guard let type = description[kIOPSTypeKey] as? String,
type == kIOPSInternalBatteryType else { continue }
foundBattery = true
if let state = description[kIOPSPowerSourceStateKey] as? String {
onAC = (state == kIOPSACPowerValue)
}
if let current = description[kIOPSCurrentCapacityKey] as? Int,
let maximum = description[kIOPSMaxCapacityKey] as? Int,
maximum > 0 {
percent = Int((Double(current) / Double(maximum) * 100.0).rounded())
}
break
}
hasBattery = foundBattery
isOnAC = foundBattery ? onAC : true
batteryPercent = percent
}
deinit {
stop()
}
}
+96
View File
@@ -0,0 +1,96 @@
import Foundation
/// UserDefaults-backed settings. Every key is also settable from the terminal,
/// e.g. `defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60`
final class Preferences {
static let shared = Preferences()
private enum Key {
static let assertions = "assertions"
static let defaultDurationMinutes = "defaultDurationMinutes"
static let activateOnLaunch = "activateOnLaunch"
static let showCountdown = "showCountdown"
static let smartOnAC = "smartOnAC"
static let smartOnExternalDisplay = "smartOnExternalDisplay"
static let smartBatteryThreshold = "smartBatteryThreshold"
static let hotKeyIndex = "hotKeyIndex"
}
private let store = UserDefaults.standard
private init() {
store.register(defaults: [
// Matches `caffeinate -dis`, which is what most people actually want.
Key.assertions: [
AssertionKind.displaySleep.rawValue,
AssertionKind.idleSystemSleep.rawValue,
AssertionKind.systemSleep.rawValue
],
Key.defaultDurationMinutes: 0, // 0 == until turned off
Key.activateOnLaunch: false,
Key.showCountdown: true,
Key.smartOnAC: false,
Key.smartOnExternalDisplay: false,
Key.smartBatteryThreshold: 0, // 0 == rule disabled
// Off by default — claiming a system-wide shortcut without being
// asked is rude, and it may collide with something already bound.
Key.hotKeyIndex: -1 // index into HotKeyPreset.all, -1 == off
])
}
/// Which assertions to hold while awake. Never allowed to be empty.
var assertions: Set<AssertionKind> {
get {
let raw = store.stringArray(forKey: Key.assertions) ?? []
let parsed = Set(raw.compactMap { AssertionKind(rawValue: $0) })
return parsed.isEmpty ? [.idleSystemSleep] : parsed
}
set {
let safe = newValue.isEmpty ? Set<AssertionKind>([.idleSystemSleep]) : newValue
store.set(safe.map { $0.rawValue }, forKey: Key.assertions)
}
}
/// Duration used by the menu bar toggle and the global shortcut.
/// 0 means indefinite. Not exposed in the menu; set it with `defaults write`.
var defaultDurationMinutes: Int {
get { store.integer(forKey: Key.defaultDurationMinutes) }
set { store.set(newValue, forKey: Key.defaultDurationMinutes) }
}
var activateOnLaunch: Bool {
get { store.bool(forKey: Key.activateOnLaunch) }
set { store.set(newValue, forKey: Key.activateOnLaunch) }
}
var showCountdown: Bool {
get { store.bool(forKey: Key.showCountdown) }
set { store.set(newValue, forKey: Key.showCountdown) }
}
var smartOnAC: Bool {
get { store.bool(forKey: Key.smartOnAC) }
set { store.set(newValue, forKey: Key.smartOnAC) }
}
var smartOnExternalDisplay: Bool {
get { store.bool(forKey: Key.smartOnExternalDisplay) }
set { store.set(newValue, forKey: Key.smartOnExternalDisplay) }
}
/// Stay awake while the battery is at or above this percentage. 0 disables.
var smartBatteryThreshold: Int {
get { store.integer(forKey: Key.smartBatteryThreshold) }
set { store.set(newValue, forKey: Key.smartBatteryThreshold) }
}
var hotKeyIndex: Int {
get { store.integer(forKey: Key.hotKeyIndex) }
set { store.set(newValue, forKey: Key.hotKeyIndex) }
}
var anySmartRuleEnabled: Bool {
smartOnAC || smartOnExternalDisplay || smartBatteryThreshold > 0
}
}
+102
View File
@@ -0,0 +1,102 @@
import Foundation
import IOKit.pwr_mgt
/// The IOKit power assertions that map onto `caffeinate` flags.
///
/// `caffeinate` is itself a thin wrapper around `IOPMAssertionCreateWithName`,
/// so holding these assertions in-process is exactly equivalent to keeping a
/// `caffeinate` process alive — minus the terminal window and the orphan risk.
enum AssertionKind: String, CaseIterable {
/// `caffeinate -d` — the display will not dim or sleep on idle.
case displaySleep = "PreventUserIdleDisplaySleep"
/// `caffeinate -i` — the system will not sleep on idle (display may still sleep).
case idleSystemSleep = "PreventUserIdleSystemSleep"
/// `caffeinate -s` — the system will not enter full sleep (dark wake is still
/// allowed). Only honoured while running on AC power.
///
/// Note: power assertions never override *user-initiated* sleep, so this does
/// NOT keep the Mac up with the lid closed. Clamshell sleep can only be
/// defeated by `sudo pmset disablesleep 1`, which has no public API.
case systemSleep = "PreventSystemSleep"
/// `caffeinate -m` — disks will not spin down on idle.
case diskIdle = "PreventDiskIdle"
/// Order used when rendering the Prevent submenu.
static let displayOrder: [AssertionKind] = [
.displaySleep, .idleSystemSleep, .systemSleep, .diskIdle
]
var title: String {
switch self {
case .displaySleep: return "Display sleep"
case .idleSystemSleep: return "System idle sleep"
case .systemSleep: return "Full system sleep (AC only)"
case .diskIdle: return "Disk spin-down"
}
}
var caffeinateFlag: String {
switch self {
case .displaySleep: return "d"
case .idleSystemSleep: return "i"
case .systemSleep: return "s"
case .diskIdle: return "m"
}
}
}
/// Owns the live IOKit power assertions.
///
/// `apply(_:)` is idempotent and declarative: it reconciles the set of held
/// assertions with the set you ask for, creating and releasing only the delta.
/// The kernel releases every assertion automatically when the process exits,
/// so a crash can never leave the Mac permanently awake.
final class SleepBlocker {
private var held: [AssertionKind: IOPMAssertionID] = [:]
/// Human-readable text shown in `pmset -g assertions`.
private let reason = "Wakeful is keeping this Mac awake"
private(set) var lastError: String?
var isBlocking: Bool { !held.isEmpty }
var heldKinds: Set<AssertionKind> { Set(held.keys) }
/// Makes the set of live assertions exactly equal to `desired`.
func apply(_ desired: Set<AssertionKind>) {
lastError = nil
for (kind, id) in held where !desired.contains(kind) {
_ = IOPMAssertionRelease(id)
held.removeValue(forKey: kind)
}
for kind in desired where held[kind] == nil {
var id: IOPMAssertionID = IOPMAssertionID(0)
let result = IOPMAssertionCreateWithName(
kind.rawValue as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
reason as CFString,
&id
)
if result == kIOReturnSuccess {
held[kind] = id
} else {
lastError = "Could not create \(kind.rawValue) (IOReturn \(result))"
}
}
}
func releaseAll() {
apply([])
}
deinit {
releaseAll()
}
}
+216
View File
@@ -0,0 +1,216 @@
import Foundation
/// The state machine. Owns the assertions, the session timer, and the
/// interaction between manual intent and the smart-activation rules.
final class WakeController {
enum Source {
case manual
case automatic
}
/// Called on the main thread whenever anything the menu displays changes.
var onChange: (() -> Void)?
private let blocker = SleepBlocker()
private let prefs = Preferences.shared
private weak var power: PowerMonitor?
private(set) var isOn = false
private(set) var source: Source = .manual
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
private var ticker: Timer?
func attach(power: PowerMonitor) {
self.power = power
}
// MARK: - Derived state
var remaining: TimeInterval? {
guard let expiry else { return nil }
return max(0, expiry.timeIntervalSinceNow)
}
var heldKinds: Set<AssertionKind> { blocker.heldKinds }
var lastError: String? { blocker.lastError }
var statusText: String {
guard isOn else {
return prefs.anySmartRuleEnabled ? "Sleep allowed — watching conditions"
: "Sleep allowed"
}
if let remaining {
return "Awake — \(WakeController.format(remaining)) left"
}
if source == .automatic, let automaticReason {
return "Awake — \(automaticReason)"
}
return "Awake — until turned off"
}
// MARK: - Intent
func toggle() {
if isOn {
turnOff(source: .manual)
} else {
turnOn(minutes: prefs.defaultDurationMinutes, source: .manual)
}
}
func turnOn(minutes: Int, source: Source, reason: String? = nil) {
blocker.apply(prefs.assertions)
isOn = blocker.isBlocking
// If every assertion failed, don't arm a phantom session with a ticking
// countdown while nothing is actually being held. lastError explains why.
guard isOn else {
setExpiry(nil)
automaticReason = nil
onChange?()
return
}
self.source = source
automaticReason = reason
if source == .manual {
suppressAutomatic = false
}
setExpiry(minutes > 0 ? Date().addingTimeInterval(TimeInterval(minutes) * 60) : nil)
onChange?()
}
/// Adds exactly `minutes` to a running timed session, without disturbing the
/// session's source, reason, assertions, or suppression state.
func extend(by minutes: Int) {
guard isOn, let expiry else { return }
setExpiry(expiry.addingTimeInterval(TimeInterval(minutes) * 60))
onChange?()
}
func turnOff(source: Source) {
if source == .manual, isOn, automaticRuleSatisfied().satisfied {
suppressAutomatic = true
}
blocker.releaseAll()
isOn = false
self.source = source
automaticReason = nil
setExpiry(nil)
onChange?()
}
/// Re-applies the current assertion set. Called after the user changes the
/// Prevent submenu while a session is running.
func reapplyAssertions() {
guard isOn else {
onChange?()
return
}
blocker.apply(prefs.assertions)
isOn = blocker.isBlocking
onChange?()
}
// MARK: - Smart activation
func automaticRuleSatisfied() -> (satisfied: Bool, reason: String?) {
guard let power, prefs.anySmartRuleEnabled else { return (false, nil) }
if prefs.smartOnAC, power.isOnAC {
return (true, "plugged in")
}
if prefs.smartOnExternalDisplay, power.externalDisplayConnected {
return (true, "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
// sitting at 100% would satisfy it permanently.
let threshold = prefs.smartBatteryThreshold
if threshold > 0, !power.isOnAC,
let percent = power.batteryPercent, percent >= threshold {
return (true, "battery at \(percent)%")
}
return (false, nil)
}
/// Called on launch and on every power / display change.
func evaluateAutomatic() {
let rule = automaticRuleSatisfied()
if !rule.satisfied {
suppressAutomatic = false
}
if rule.satisfied, !isOn, !suppressAutomatic {
turnOn(minutes: 0, source: .automatic, reason: rule.reason)
} else if !rule.satisfied, isOn, source == .automatic {
turnOff(source: .automatic)
} else if rule.satisfied, isOn, source == .automatic,
automaticReason != rule.reason {
automaticReason = rule.reason
onChange?()
}
}
/// Called when the smart rules themselves are edited.
func smartRulesChanged() {
if !prefs.anySmartRuleEnabled, isOn, source == .automatic {
turnOff(source: .automatic)
return
}
evaluateAutomatic()
onChange?()
}
// MARK: - Session timer
private func setExpiry(_ date: Date?) {
expiry = date
ticker?.invalidate()
ticker = nil
guard date != nil else { return }
let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
self?.tick()
}
// .common lets the countdown keep updating while a menu is open.
RunLoop.main.add(timer, forMode: .common)
ticker = timer
}
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)
evaluateAutomatic()
} else {
onChange?()
}
}
// MARK: - Helpers
static func format(_ interval: TimeInterval) -> String {
let total = Int(interval.rounded())
let hours = total / 3600
let minutes = (total % 3600) / 60
let seconds = total % 60
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
}
return String(format: "%d:%02d", minutes, seconds)
}
}
+10
View File
@@ -0,0 +1,10 @@
import AppKit
// Wakeful has no windows and no Dock icon. `LSUIElement` in Info.plist covers
// the bundled case; setting the activation policy here also covers running the
// raw binary from a terminal during development.
let application = NSApplication.shared
let delegate = AppDelegate()
application.delegate = delegate
application.setActivationPolicy(.accessory)
application.run()