Skip to content

Latest commit

 

History

History
168 lines (110 loc) · 16.4 KB

File metadata and controls

168 lines (110 loc) · 16.4 KB

Vigil v0.2.0 — design

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.

What changes

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.

High-level architecture

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.

Process boundaries and "why both features run as agents"

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:

  1. 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.

  2. 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).

  3. 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.

Time-limited Lid-Awake requires approval (and why)

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.

CLI surface

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.

Status output and JSON schema

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.

Persistence and expiry

Each feature has:

  • state-<feature>.json — the FeatureSession record (feature, enabledAt, duration), written atomically.
  • sentinel-<feature> — empty file, touched on enable, removed on disable, NEVER rewritten. The launchd KeepAlive.PathState watch 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.

Sparkle integration

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 1 flag 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.

Menu-bar UI

Status-bar icon

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.

Popover

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.

Turn-Off-All

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.

Notifications

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.

Edge cases worth knowing

  • Both features active simultaneously. Safe per IOKit — powerd aggregates assertions across processes and chooses the strongest state. pmset -g assertions will list two PIDs, each holding its own set, with distinct named: strings ("Vigil Lid-Awake: …" and "Vigil Caffeinate: …") so the source is self-documenting.

  • Manual Apple-menu Sleep. Caffeinate doesn't prevent it (PreventUserIdleSystemSleep is idle-only). Lid-Awake does (pmset disablesleep 1 is 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 -9 on the agent process. IOKit power assertions are reaped by the kernel on task termination — process-scoped, released on exit regardless of cause. pmset -g assertions never shows assertions held by dead PIDs.

  • AssociatedBundleIdentifiers and Login Items. Both feature LaunchAgents associate with com.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 in README.md.

What was deliberately rejected

  • Caffeinate in-process — would lose timer state on Sparkle relaunch and force two divergent code paths.
  • vigil lid on/off (shorter command) — vigil lid on reads ambiguously ("turn lid on?"); vigil lid-awake on is 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.fill for "both active" — too easily confused with battery charging; sun.max.fill reads 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.PathState flapping. Separating sentinel-<feature> (immutable while active) from state-<feature>.json (mutable) removes that risk for zero cost.

File-by-file overview

  • Package.swift — adds VigilCore target.
  • 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 (StatusReport builder + 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 app produces 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).