Design rationale for v0.2.0. Produced by a dual-model consensus design process (two independent reasoning models investigated the same brief, cross-pollinated positions, and ran a third blind-spot critique pass) before any code was written. Investigation artifacts live in the session workspace; this document is the synthesised, scoped, implemented result.
v0.2.0 adds a second feature, a Caffeinate mode that holds two IOKit power assertions to keep the display and system awake on demand. It also adds a shared 8-entry time-limit dropdown to both features (the existing Lid-Awake and the new Caffeinate). When a timed session reaches its deadline the feature auto-disables and the system returns to its previous behaviour.
The menu-bar UX is rebuilt around two stacked feature cards (each with its own toggle, duration picker, live countdown, and opt-in expiry notification). The CLI surface is rebuilt around feature-first verbs (vigil <feature> on/off). Internally the two features share one persistence model, one timed-expiry engine, and one JSON status schema, so neither feature is bespoke and adding a third feature later costs nothing.
Both features are LaunchAgent-backed by design. The menu app is a pure controller: it does not hold IOKit assertions itself. The bundled vigil CLI is the source of truth for state and for assertion-holding; the menu app shells out to it for every action and decodes vigil status --json for every read. Quitting the menu app does not stop active features — that is the whole point of running them as user LaunchAgents — so a multi-hour timed session survives a Sparkle update or a user-initiated menu-app quit.
┌──────────────┐ shell-out (Helper.run) ┌──────────┐
│ VigilMenuBar │ ───────────────────────────▶ │ vigil │ (CLI)
│ (SwiftUI) │ ◀─────────────────────────── │ │
└──────────────┘ vigil status --json (JSON) └──────────┘
│
│ vigil <feature> on
│ - writes state-<feature>.json
│ - touches sentinel-<feature>
│ - installs com.vigil.app.<feature>.plist
│ - launchctl bootstrap gui/$UID …
▼
┌──────────────────┐
│ launchd user │
│ agent (per-feat) │
│ vigil hold │
│ <feature> │
└──────────────────┘
Two LaunchAgents, one per feature, both gated on a per-feature sentinel file (~/Library/Application Support/Vigil/sentinel-<feature>) via launchd's KeepAlive.PathState. The sentinel is a separate file from the session record (state-<feature>.json) so that atomic rewrites of the session — which briefly unlink the inode — cannot cause launchd to flap the agent.
Caffeinate could in theory live entirely in the menu-app process: IOPMAssertionCreateWithName needs no entitlement, no sudo, no privileged helper. The dual-model consensus initially split on this. After cross-pollination both reasoning paths converged on LaunchAgent-backed Caffeinate for three reasons:
-
It is the DRY win the brief asks for. One generalised
vigil hold <feature>engine, parameterised on the assertion set and the state-file path, is structurally simpler than two parallel code paths (one in-process, one in-CLI). A future third feature inherits the engine for free. -
It preserves a session across a Sparkle update. When Sparkle replaces
Vigil.app, the menu app quits — if Caffeinate were holding assertions in-process, they'd die with the process. A LaunchAgent keeps holding them across the swap (the menu app boots out the agent just-in-time so the new CLI Mach-O can replace the old, and re-bootstraps it on relaunch). -
It matches the product mental model. Vigil exists for unattended jobs — "I closed the lid and walked away." Silently dropping a multi-hour timer because the menu app quit is exactly the failure mode that justifies the architecture.
Caffeinate needs no privileged ops, so the LaunchAgent path costs nothing in terms of trust surface — the sudoers allowlist does not grow, and the Approve All flow remains entirely a Lid-Awake concern.
When a Lid-Awake timer expires, the agent must restore the previously-saved pmset profile (disablesleep, sleep, disksleep, ttyskeepawake, tcpkeepalive). That is a privileged operation. The agent process runs in gui/<uid> with no tty — interactive sudo is impossible. The only viable route from inside the agent is the scoped one-time-approval helper at /Library/PrivilegedHelperTools/com.vigil.app.helper, reached via the in-process --approved-helper flag.
Therefore: the Lid-Awake LaunchAgent's ProgramArguments embeds --approved-helper, and the menu app refuses to start a time-limited Lid-Awake session unless the helper is approved. Indefinite Lid-Awake still works without approval (the user disables it manually, interactively, with --admin-prompt).
Caffeinate has no pmset to restore, so its agent runs with no privilege flag at all.
vigil status [--json]
vigil doctor
vigil lid-awake on [--duration <preset>] [--force-battery] [--no-dim-display] [--no-dim-keyboard]
vigil lid-awake off
vigil lid-awake toggle [--duration <preset>] [--force-battery]
vigil caffeinate on [--duration <preset>] [--force-battery]
vigil caffeinate off
vigil caffeinate toggle [--duration <preset>]
vigil approve-all
vigil approval-status
vigil --version
<preset> is one of indefinite | 5m | 10m | 15m | 30m | 1h | 2h | 5h. Default: indefinite. The matching Duration enum in Sources/VigilCore/Duration.swift is RawRepresentable<String>, Codable, CaseIterable, Identifiable, Sendable — usable from the CLI argument parser, the SwiftUI @AppStorage-backed dropdown, and the on-disk JSON state with no glue.
The CLI surface is feature-first; there are no vigil on / vigil off aliases. The Duration enum is RawRepresentable<String>, so the same definition serves as the CLI's --duration parse target, the SwiftUI @AppStorage-backed dropdown's storage type, and the on-disk JSON state shape with no glue between them.
vigil status --json is the canonical machine-readable surface; the human format is the default for terminal users. The schema lives in Sources/VigilCore/StatusReport.swift with schemaVersion = 1 so future versions can evolve cleanly. The menu app decodes StatusReport directly — there is no stdout-line scraping.
vigil doctor emits the JSON first (for the menu app's clipboard-copy diagnostics flow), followed by a verbose human summary with pmset -g and pmset -g assertions for terminal use.
Each feature has:
state-<feature>.json— theFeatureSessionrecord (feature,enabledAt,duration), written atomically.sentinel-<feature>— empty file, touched on enable, removed on disable, NEVER rewritten. The launchdKeepAlive.PathStatewatch points here.
The agent process schedules expiry with two wall-clock-deadline timers: one DispatchSourceTimer(wallDeadline:) for the exact deadline, plus a second DispatchSourceTimer(wallDeadline:) rescheduled every 60 seconds as belt-and-suspenders. (We do not use Timer.scheduledTimer for the belt-and-suspenders: its underlying CFRunLoopTimer is monotonic and pauses across sleep, which is exactly the case the belt is supposed to cover.)
A third DispatchSourceTimer polls the sentinel once per second — if a sibling process removed the sentinel (e.g. vigil <feature> off from the menu app), the agent self-exits cleanly. launchd would reap it eventually via KeepAlive.PathState anyway; the watchdog just makes the release prompt.
SparkleDelegate.updater(_:willInstallUpdate:) boots out both feature LaunchAgent labels before the CLI Mach-O is replaced. On relaunch, AppCoordinator.reArmAgentsIfNeeded() walks the persisted sessions: for any feature whose session is still within its window, it re-runs the feature's enable command — which always rewrites the LaunchAgent plist with the current executable path before bootstrapping, so a fresh-install bundle-path change doesn't strand the agent against a deleted Mach-O.
The few-second window between Sparkle's bootout and the new menu app's re-arm is benign:
- For Lid-Awake, the
pmset disablesleep 1flag stays in effect throughout — that's the actual sleep-prevention; the IOKit assertions are belt-and-suspenders on top. - For Caffeinate, the assertions are only defending against idle sleep. The macOS minimum display-idle timer is 1 minute; Sparkle relaunch is typically 5–15 seconds. Net: invisible to the user.
Single switching SF Symbol — composite stacked glyphs make the status-bar item grow/shrink with state, which looks amateur. All four symbols ship in macOS 13.0 so there is no fallback path to maintain.
| State | Symbol | Meaning |
|---|---|---|
| neither active | moon.zzz |
idle |
| caffeinate only | eye.fill |
watching/awake |
| lid-awake only | laptopcomputer |
lid context |
| both active | sun.max.fill |
maximum stay-awake |
eye.fill for Caffeinate is also the "vigil" semantic the app is named after, so the icon doubles as branding when the feature is the one the user is interacting with most.
MenuBarExtra(.window) popover, 400 px wide, with the two feature cards stacked, lid-awake's visual-options checkboxes and approval banner nested inside the lid-awake card (those concerns only apply to that feature), a collapsed Diagnostics disclosure, and a footer bar with refresh / doctor / updates / Turn-Off-All / revoke-approval / quit icons.
The countdown in each active card uses TimelineView(.periodic(from: .now, by: 1)). TimelineView is supposed to suspend redraws when its view tree is offscreen; in MenuBarExtra(.window) the popover tears down on close. As defence in depth against early-macOS-13 MenuBarExtra lifecycle bugs, MenuContentView flips a @State flag in .onAppear / .onDisappear — if the suspension ever stops working in practice, that flag is the place to gate the TimelineView.
A small stop.circle button in the footer, visible only when at least one feature is active. Not a right-click context menu on the menu-bar label — MenuBarExtra(.window) does not expose a public SwiftUI hook for right-click context menus on its label, so the right-click approach simply doesn't work without dropping out of SwiftUI to raw NSStatusItem.
Opt-in per feature (Notify when timer ends checkbox inside each card). The app does not call UNUserNotificationCenter.requestAuthorization at first launch — that's the most-hated dark pattern on macOS. Authorisation is only requested the moment the user enables the toggle; if denied, the toggle bounces back to off with a lastMessage line explaining why. The notification body says "Caffeinate ended." / "Lid-Awake ended." — Vigil-internal language, no metaphor borrowing.
-
Both features active simultaneously. Safe per IOKit —
powerdaggregates assertions across processes and chooses the strongest state.pmset -g assertionswill list two PIDs, each holding its own set, with distinctnamed:strings ("Vigil Lid-Awake: …"and"Vigil Caffeinate: …") so the source is self-documenting. -
Manual Apple-menu Sleep. Caffeinate doesn't prevent it (
PreventUserIdleSystemSleepis idle-only). Lid-Awake does (pmset disablesleep 1is a power-domain-wide override). The card subtitle copy surfaces the difference: caffeinate says"Manual Sleep and lid close still send the Mac to sleep", lid-awake says"Manual Sleep is also blocked". -
AC unplugged mid-session. Lid-Awake refuses to start on battery unless
--force-battery(CLI) or a second-click confirmation (menu app) is given. Caffeinate allows battery by default, since the user is present and consciously enabling display-on power. -
Sleep/wake cycles while a timer is running. Wall-clock deadlines via
DispatchSourceTimer(wallDeadline:)fire correctly across sleep. The 60-second belt-and-suspenders catches the (rare, never personally observed but documented in older radar history) case where the primary timer doesn't fire after wake. -
System date change. Wall-clock deadlines respect the new clock. NTP slews are sub-second and harmless; user-initiated large clock changes are user-initiated and rare.
-
kill -9on the agent process. IOKit power assertions are reaped by the kernel on task termination — process-scoped, released on exit regardless of cause.pmset -g assertionsnever shows assertions held by dead PIDs. -
AssociatedBundleIdentifiersand Login Items. Both feature LaunchAgents associate withcom.vigil.app, so macOS System Settings → General → Login Items shows one "Vigil" entry that toggles both background agents together. This is fine — the per-feature on/off lives in the Vigil popover, and the System Settings toggle is the user's escape hatch when everything else fails. Documented inREADME.md.
- Caffeinate in-process — would lose timer state on Sparkle relaunch and force two divergent code paths.
vigil lid on/off(shorter command) —vigil lid onreads ambiguously ("turn lid on?");vigil lid-awake onis self-describing.- Composite stacked menu-bar icon — would double the menu-bar width and risk redraw flicker; a single switching glyph is the macOS HIG convention.
- Notification permission prompt at first launch — opt-in only.
bolt.fillfor "both active" — too easily confused with battery charging;sun.max.fillreads cleanly as "fully awake."- Right-click context menu on the menu-bar icon for "Turn Off All" —
MenuBarExtra(.window)swallows right-click. The footer button works. - One file, one sentinel — atomic rewrites can briefly unlink the inode and trigger launchd
KeepAlive.PathStateflapping. Separatingsentinel-<feature>(immutable while active) fromstate-<feature>.json(mutable) removes that risk for zero cost.
Package.swift— addsVigilCoretarget.Sources/VigilIdentifiers/VigilIdentifiers.swift— per-feature LaunchAgent labels, IPC contract version, sudoers verb list, translocation predicate.Sources/VigilCore/—Feature,Duration,FeatureSession,FeatureStateStore,Paths,StatusReport.Sources/vigil/— split into focused files:main.swift(slim dispatcher),Shell.swift,State.swift,Power.swift,Privilege.swift(privileged-ops + helper-install),LaunchAgent.swift(per-feature plist install/bootstrap/bootout),Features.swift(LidAwakeController,CaffeinateController,HoldEngine),Status.swift(StatusReportbuilder + JSON/human emit).Sources/VigilMenuBar/— split from one 840-line file into:VigilMenuBarApp.swift(entry),Helper.swift(subprocess executor +ApprovedHelperInstaller+AppLocation),Sparkle.swift(UpdateController+SparkleDelegate),Coordinator.swift(AppCoordinator+FeatureViewModel+ExpiryNotifier),Views.swift(all SwiftUI views).App/Info.plist,App/Vigil.entitlements,App/Helper.entitlements— unchanged.Makefile— unchanged.make appproduces a working .app with the new VigilCore target linked into both executables.README.md— rewritten "Use" / "How it works" / "Trust model" sections covering both features.RELEASING.md— unchanged (read-only per brief).