initial commit
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user