initial commit
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
.build/
|
||||||
|
dist/
|
||||||
|
.swiftpm/
|
||||||
|
*.xcodeproj
|
||||||
|
*.xcworkspace
|
||||||
|
.DS_Store
|
||||||
|
Package.resolved
|
||||||
Vendored
+24
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"type": "swift",
|
||||||
|
"request": "launch",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder:Wakeful}",
|
||||||
|
"name": "Debug Wakeful",
|
||||||
|
"target": "Wakeful",
|
||||||
|
"configuration": "debug",
|
||||||
|
"preLaunchTask": "swift: Build Debug Wakeful"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "swift",
|
||||||
|
"request": "launch",
|
||||||
|
"args": [],
|
||||||
|
"cwd": "${workspaceFolder:Wakeful}",
|
||||||
|
"name": "Release Wakeful",
|
||||||
|
"target": "Wakeful",
|
||||||
|
"configuration": "release",
|
||||||
|
"preLaunchTask": "swift: Build Release Wakeful"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Gary Hansen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
APP_NAME := Wakeful
|
||||||
|
BUNDLE_ID := com.github.wakeful.Wakeful
|
||||||
|
VERSION := 1.0.0
|
||||||
|
|
||||||
|
# Override with: make SIGN_ID="Developer ID Application: Your Name (TEAMID)"
|
||||||
|
SIGN_ID ?= -
|
||||||
|
|
||||||
|
# ?= so `bundle` can be re-invoked with a different source directory.
|
||||||
|
BUILD_DIR ?= .build/release
|
||||||
|
UNIVERSAL_DIR := .build/apple/Products/Release
|
||||||
|
DIST := dist
|
||||||
|
APP := $(DIST)/$(APP_NAME).app
|
||||||
|
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
|
||||||
|
|
||||||
|
all: app
|
||||||
|
|
||||||
|
## Build for this Mac's architecture only (fast).
|
||||||
|
build:
|
||||||
|
swift build -c release
|
||||||
|
|
||||||
|
## Build a universal (arm64 + x86_64) binary. Note SwiftPM writes multi-arch
|
||||||
|
## builds to a different directory, which is why `bundle` is parameterised.
|
||||||
|
universal:
|
||||||
|
swift build -c release --arch arm64 --arch x86_64
|
||||||
|
|
||||||
|
## Assemble and sign Wakeful.app into ./dist
|
||||||
|
app: build bundle
|
||||||
|
|
||||||
|
## Same, from a universal binary.
|
||||||
|
app-universal: universal
|
||||||
|
$(MAKE) bundle BUILD_DIR=$(UNIVERSAL_DIR)
|
||||||
|
|
||||||
|
bundle:
|
||||||
|
rm -rf "$(APP)"
|
||||||
|
mkdir -p "$(CONTENTS)/MacOS" "$(CONTENTS)/Resources"
|
||||||
|
cp "$(BUILD_DIR)/$(APP_NAME)" "$(CONTENTS)/MacOS/$(APP_NAME)"
|
||||||
|
sed -e 's|__BUNDLE_ID__|$(BUNDLE_ID)|g' \
|
||||||
|
-e 's|__VERSION__|$(VERSION)|g' \
|
||||||
|
Resources/Info.plist > "$(CONTENTS)/Info.plist"
|
||||||
|
printf 'APPL????' > "$(CONTENTS)/PkgInfo"
|
||||||
|
$(MAKE) sign
|
||||||
|
@echo ""
|
||||||
|
@echo "Built $(APP)"
|
||||||
|
@echo "Run 'make install' to copy it to /Applications and launch it."
|
||||||
|
|
||||||
|
## Code-sign the bundle. Ad-hoc by default; pass SIGN_ID for a real identity.
|
||||||
|
sign:
|
||||||
|
codesign --force --options runtime $(TS) --sign "$(SIGN_ID)" "$(APP)"
|
||||||
|
codesign --verify --verbose=2 "$(APP)"
|
||||||
|
|
||||||
|
## Install to /Applications and launch. Login-item registration needs the app
|
||||||
|
## to live in a stable location, so install before enabling Launch at Login.
|
||||||
|
install: app
|
||||||
|
- osascript -e 'quit app "$(APP_NAME)"' 2>/dev/null || true
|
||||||
|
rm -rf "/Applications/$(APP_NAME).app"
|
||||||
|
cp -R "$(APP)" /Applications/
|
||||||
|
open "/Applications/$(APP_NAME).app"
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
- osascript -e 'quit app "$(APP_NAME)"' 2>/dev/null || true
|
||||||
|
rm -rf "/Applications/$(APP_NAME).app"
|
||||||
|
defaults delete $(BUNDLE_ID) 2>/dev/null || true
|
||||||
|
@echo "Removed. If Launch at Login was on, also check"
|
||||||
|
@echo "System Settings > General > Login Items & Extensions."
|
||||||
|
|
||||||
|
## Run the built binary straight from the terminal (for debugging).
|
||||||
|
## Note: Preferences and Launch at Login only behave correctly in the .app bundle.
|
||||||
|
run: build
|
||||||
|
"$(BUILD_DIR)/$(APP_NAME)"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
swift package clean
|
||||||
|
rm -rf .build "$(DIST)"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// swift-tools-version:5.9
|
||||||
|
//
|
||||||
|
// Deliberately pinned to tools 5.9 / Swift 5 language mode. Under Swift 6's
|
||||||
|
// strict concurrency checking, the static mutable state in HotKey's hot key
|
||||||
|
// registry and Preferences.shared become hard errors, and the @convention(c)
|
||||||
|
// Carbon event handler's access to that registry becomes an actor-isolation
|
||||||
|
// error. Migrating means marking HotKey @MainActor plus nonisolated(unsafe)
|
||||||
|
// on the registry, or moving it behind a lock.
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Wakeful",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.executableTarget(
|
||||||
|
name: "Wakeful",
|
||||||
|
path: "Sources/Wakeful"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Wakeful
|
||||||
|
|
||||||
|
A menu bar switch for macOS sleep. Does what `caffeinate -dis` does, without a
|
||||||
|
terminal window pinned open for the rest of the day.
|
||||||
|
|
||||||
|
Lives in the menu bar, toggles with a global shortcut, starts at login, and can
|
||||||
|
turn itself on when you plug in or connect an external display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
`caffeinate` is the right tool with the wrong interface. It's a foreground
|
||||||
|
process, so it needs a terminal window that you can't close, that survives
|
||||||
|
nothing, and that tells you nothing about its own state. Wakeful holds *exactly
|
||||||
|
the same kernel assertions* — it just does it from a background app with a
|
||||||
|
switch.
|
||||||
|
|
||||||
|
Verify that for yourself at any time:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pmset -g assertions
|
||||||
|
```
|
||||||
|
|
||||||
|
You'll see `PreventUserIdleDisplaySleep`, `PreventUserIdleSystemSleep`, and
|
||||||
|
`PreventSystemSleep` attributed to Wakeful, with the reason string
|
||||||
|
"Wakeful is keeping this Mac awake".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Rather than shelling out to `/usr/bin/caffeinate` and babysitting a child
|
||||||
|
process, Wakeful calls `IOPMAssertionCreateWithName` directly — which is all
|
||||||
|
`caffeinate` itself does. This means:
|
||||||
|
|
||||||
|
- No subprocess, no orphaned `caffeinate` in Activity Monitor if the app dies.
|
||||||
|
- The kernel releases every assertion automatically on process exit, so a crash
|
||||||
|
can never leave your Mac permanently awake.
|
||||||
|
- Assertions are named and attributed properly in `pmset -g assertions`.
|
||||||
|
|
||||||
|
### Assertion → `caffeinate` mapping
|
||||||
|
|
||||||
|
| Menu item | IOKit assertion | `caffeinate` |
|
||||||
|
|------------------------------------|---------------------------------|--------------|
|
||||||
|
| Display sleep | `PreventUserIdleDisplaySleep` | `-d` |
|
||||||
|
| System idle sleep | `PreventUserIdleSystemSleep` | `-i` |
|
||||||
|
| Full system sleep (AC only) | `PreventSystemSleep` | `-s` |
|
||||||
|
| Disk spin-down | `PreventDiskIdle` | `-m` |
|
||||||
|
|
||||||
|
Default is `-d -i -s`, i.e. `caffeinate -dis`. The Prevent submenu
|
||||||
|
shows the equivalent command for whatever you've selected.
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/Wakeful/
|
||||||
|
main.swift NSApplication bootstrap, .accessory activation policy
|
||||||
|
AppDelegate.swift NSStatusItem + the whole menu, rebuilt on every open
|
||||||
|
WakeController.swift State machine: on/off, source, expiry, smart rules
|
||||||
|
SleepBlocker.swift Owns live IOPMAssertionIDs; declarative apply(Set)
|
||||||
|
PowerMonitor.swift IOPS notifications (AC / charge) + screen-change notifications
|
||||||
|
Preferences.swift UserDefaults, all keys settable via `defaults write`
|
||||||
|
HotKey.swift Carbon RegisterEventHotKey global shortcut
|
||||||
|
LaunchAtLogin.swift SMAppService.mainApp registration
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**`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 }`
|
||||||
|
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`):
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
### Global shortcut
|
||||||
|
|
||||||
|
Uses the Carbon `RegisterEventHotKey` API, deliberately **not** `CGEventTap` or
|
||||||
|
`NSEvent.addGlobalMonitorForEvents`. The latter two would drag the user through
|
||||||
|
an Accessibility / Input Monitoring permission prompt; `RegisterEventHotKey`
|
||||||
|
needs no permission and no entitlement at all.
|
||||||
|
|
||||||
|
v1 ships three presets (`⌃⌥⌘A`, `⌃⌥⌘K`, `⇧⌥⌘Z`) rather than a recorder UI, and
|
||||||
|
none is enabled by default — an app shouldn't claim a system-wide key
|
||||||
|
combination without being asked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Requires Xcode or the Command Line Tools (`xcode-select --install`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git clone <your-repo-url> wakeful
|
||||||
|
cd wakeful
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` builds in release mode, assembles `Wakeful.app`, ad-hoc
|
||||||
|
code-signs it, copies it to `/Applications`, and launches it.
|
||||||
|
|
||||||
|
Other targets:
|
||||||
|
|
||||||
|
| Command | Effect |
|
||||||
|
|------------------|--------|
|
||||||
|
| `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 uninstall` | Quit, remove from `/Applications`, delete preferences |
|
||||||
|
| `make clean` | Remove build artifacts |
|
||||||
|
|
||||||
|
To sign with a real Developer ID instead of ad-hoc:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make install SIGN_ID="Developer ID Application: Your Name (TEAMID)"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also just `open Package.swift` to work on it in Xcode, but note that
|
||||||
|
Preferences and Launch at Login only behave correctly from the assembled bundle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Click the cup icon in the menu bar.
|
||||||
|
|
||||||
|
- **Turn On / Turn Off** — indefinite session. Same as the global shortcut.
|
||||||
|
- **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.
|
||||||
|
- **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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
defaults write com.github.wakeful.Wakeful defaultDurationMinutes -int 60
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **Ad-hoc signing.** `make app` signs with `-`, which is fine on the machine
|
||||||
|
that built it. Anyone downloading a prebuilt binary would need a notarized
|
||||||
|
build or a right-click → Open. Building from source sidesteps this entirely,
|
||||||
|
which is why there are no releases.
|
||||||
|
- **Power assertions cannot stop lid-close sleep.** This is the one thing people
|
||||||
|
expect and don't get. Assertions never override *user-initiated* sleep, so
|
||||||
|
`PreventSystemSleep` keeps the Mac out of full sleep while idle on AC but does
|
||||||
|
nothing when you shut the lid. Clamshell sleep can only be defeated with
|
||||||
|
`sudo pmset disablesleep 1`, which has no public API and is deliberately out
|
||||||
|
of scope here.
|
||||||
|
- **`PreventSystemSleep` is AC-only.** macOS ignores it on battery entirely.
|
||||||
|
- **Login item approval.** macOS may put the registration in a
|
||||||
|
`requiresApproval` state the first time; the menu says so, and it's cleared in
|
||||||
|
System Settings ▸ General ▸ Login Items & Extensions. `SMAppService` requires
|
||||||
|
a code-signed bundle; ad-hoc signing is expected to satisfy that locally, but
|
||||||
|
if Launch at Login errors out on your machine, a real Developer ID is the fix.
|
||||||
|
- **Smart activation is coarse.** "On AC" doesn't distinguish a charger from a
|
||||||
|
dock. If you want "awake while on this specific display", that's a v1.1
|
||||||
|
problem.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
Deliberately out of scope for v1, in rough priority order:
|
||||||
|
|
||||||
|
1. Hotkey recorder UI instead of presets.
|
||||||
|
2. Per-app rules — stay awake while Zoom / a specific process is running. This
|
||||||
|
is what people actually reach for after timed sessions.
|
||||||
|
3. A real Settings window (`NSWindow`), once the menu stops being enough.
|
||||||
|
4. Optional notification when a timed session expires.
|
||||||
|
5. Battery-drain guard: auto-release below a floor even during a manual session.
|
||||||
|
6. App icon + notarized release artifacts, if this is ever shared widely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>Wakeful</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>__BUNDLE_ID__</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>Wakeful</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>Wakeful</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>__VERSION__</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>__VERSION__</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>13.0</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>MIT License</string>
|
||||||
|
<key>NSSupportsAutomaticTermination</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSSupportsSuddenTermination</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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