// tray/install — idempotent lifecycle of the SwiftBar plugin that hosts the fleet // dashboard. The plugin FILE is a thin shim: it execs the installed `iapeer` binary // (`iapeer tray render --stream`), so ALL rendering/streaming logic lives in the // binary (TypeScript, tested) rather than in brittle shell — the .sh is stable and // carries only SwiftBar metadata. // // SwiftBar facts (verified against SwiftBar/PreferencesStore.swift): // • PluginDirectory is a PLAIN String default (read via expandingTildeInPath), NOT // a security-scoped bookmark — `defaults write com.ameba.SwiftBar PluginDirectory // -string ` set BEFORE first launch is honored and skips the folder picker. // • SwiftBar is not sandboxed (it runs arbitrary scripts), so a hidden ~/.iapeer // path is readable without a TCC prompt. // // Packaging (owner decision 05.07.2026): the tray ships WITH the foundation (same // class as the `iapeer list` TUI — another face of the daemon). `iapeer install` // drops the plugin file (inert without SwiftBar); `iapeer tray install [--app]` is // the activation verb that also installs SwiftBar.app when absent (owner-sanctioned) // and launches it. Tearing down the GUI layer NEVER touches the fleet. // // Paths route through cfg/env (the dedicated plugin dir is under resolveGlobalRoot, // so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew / // open) goes through an injectable Runner so tests stay hermetic. import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs' import { spawnSync } from 'child_process' import { homedir } from 'os' import { join } from 'path' import { resolveGlobalRoot } from '../storage/index.ts' import { iapeerBinPath } from '../install/index.ts' import { IAPEER_PLIST_OWNER_KEY, bootoutLaunchdJob, bootstrapLaunchdJob, cycleLaunchdJob, isFoundationOwnedPlist, launchAgentsDir, } from '../launch/launchd.ts' import { resolveFleetAddress } from './client.ts' /** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the * streaming process exits (daemon-down backoff); while the stream is alive it is * ignored. The basename is stable — install rewrites the same file (idempotent). */ export const PLUGIN_BASENAME = 'iapeer.10s.sh' const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar' // SwiftBar's `defaults` domain IS its bundle identifier — reuse it for the autostart // LaunchAgent's `open -b ` (bundle-id launch survives the app moving/renaming // between /Applications and ~/Applications, unlike a baked absolute path or `-a `). const SWIFTBAR_BUNDLE_ID = SWIFTBAR_DOMAIN // The login-autostart LaunchAgent's label. Foundation namespace (`com.agfpd.*` = the // foundation's own jobs), DELIBERATELY not `com.iapeer.` — that namespace // is the persistent-peer fleet keyed on personality, and the tray autostart is neither a // peer nor daemon-managed. Distinct label ⇒ it can never be mistaken for a peer plist by // the H4 launchd-owned sweep-guard. const SWIFTBAR_AUTOSTART_LABEL = 'com.agfpd.iapeer.tray' const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')] export type RunResult = { status: number | null; stdout: string; stderr: string } export type Runner = (cmd: string, args: string[]) => RunResult const defaultRun: Runner = (cmd, args) => { const r = spawnSync(cmd, args, { encoding: 'utf8' }) return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' } } /** The dedicated iapeer-owned plugin dir under ~/.iapeer (IAPEER_ROOT-isolated). */ export function dedicatedPluginDir(env: NodeJS.ProcessEnv = process.env): string { return join(resolveGlobalRoot(env), 'tray', 'plugins') } /** Is SwiftBar.app installed? (either /Applications or ~/Applications). */ export function swiftBarInstalled(): boolean { return SWIFTBAR_APP_PATHS.some(p => existsSync(p)) } /** The plugin-dir SwiftBar is currently configured with (plain-string default, * ~-expanded), or undefined when unset/unreadable. */ export function readSwiftBarPluginDir(run: Runner = defaultRun): string | undefined { const r = run('defaults', ['read', SWIFTBAR_DOMAIN, 'PluginDirectory']) if (r.status !== 0) return undefined const raw = r.stdout.trim() if (!raw) return undefined return raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw } function setSwiftBarPluginDir(dir: string, run: Runner): RunResult { return run('defaults', ['write', SWIFTBAR_DOMAIN, 'PluginDirectory', '-string', dir]) } function installSwiftBarApp(run: Runner): RunResult { // --no-quarantine: an automated install has no one to click through the Gatekeeper // "app downloaded from the Internet" dialog — quarantine would stall first launch. return run('brew', ['install', '--cask', '--no-quarantine', 'swiftbar']) } /** Strip any residual quarantine xattr from the app (belt-and-suspenders: an already * brew-installed SwiftBar, or a brew that quarantined despite the flag, would else * block first launch on a headless / no-click box). Best-effort. */ function dequarantineApp(run: Runner): void { const app = SWIFTBAR_APP_PATHS.find(p => existsSync(p)) if (app) run('xattr', ['-dr', 'com.apple.quarantine', app]) } function launchSwiftBar(run: Runner): void { run('open', ['-g', '-a', 'SwiftBar']) } function refreshSwiftBar(run: Runner): void { run('open', ['-g', 'swiftbar://refreshallplugins']) } /** The plugin shim: SwiftBar metadata + exec of the streaming renderer. binPath is * embedded absolute (SwiftBar's PATH is minimal, like launchd). */ export function buildPluginShim(binPath: string): string { return [ '#!/bin/bash', '# iapeer fleet', '# v1', '# iapeer', '# iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.', '# streamable', // NB: deliberately NOT hiding SwiftBar's own service items (Run-in-Terminal / // Disable / Last-Updated / Preferences / Refresh). They are the right-click "do // stuff" menu the owner expects; hiding them made the icon feel dead. Minimalism is // for OUR content (status + peer rows), not SwiftBar's utility menu. // The dashboard ONLY ever talks to the local daemon (unix socket / loopback), so it // must never route through an HTTP proxy. Bun's fetch caches proxy config at process // start and ignores NO_PROXY for loopback, so clearing the vars must happen in the // ENV before the binary launches (a runtime delete is too late). unset here. 'unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy ALL_PROXY all_proxy', `exec "${binPath}" tray render --stream`, '', ].join('\n') } // ───────────────────────────────────────────────────────────────────────────── // Tray-host supervision — a KeepAlive LaunchAgent running a tiny /bin/sh watchdog loop // that brings SwiftBar up at login AND brings it back within seconds whenever it dies. // The tray is the owner's fleet-visibility surface: a silently-dead SwiftBar hides the // state of the whole system (owner incident 13.07.2026), so its lifetime is supervised, // not fire-and-forget. The deliberate consequence: quitting SwiftBar by hand also // relaunches it — the supported off-switch is `iapeer tray uninstall`. // // MECHANISM CHOICE (owner-relevant): a user LaunchAgent, not a System Events "Login // Item" and not SMAppService. // • System Events Login Item (osascript `make login item`) — the manual stop-gap — // requires the INVOKING process to hold Automation TCC over System Events; that // grant is attributed to whatever parent runs the activation verb (Terminal / the // daemon / an onboard run), so it silently no-ops or prompts in a headless / update // context. A LaunchAgent needs no TCC at all — pure filesystem + launchctl. // • SMAppService (macOS 13+) is the modern path, but it registers a helper the TARGET // app ships — we cannot register a THIRD-PARTY GUI app (SwiftBar) with it from a CLI. // • The LaunchAgent reuses the foundation's proven idempotent plist lifecycle // (write-if-changed + undead-safe bootstrap/cycle, sandbox-guarded) and carries the // ownership sentinel so uninstall can recognize + remove exactly our artifact. // // WATCHDOG SHAPE: launchd must NOT KeepAlive `open` itself — `open` exits the moment // SwiftBar is up, so KeepAlive on it is a respawn storm (the 0.4.82 lesson). Instead the // job runs a long-lived poll loop: every 5 s, if no SwiftBar process exists, `open -g -b` // it (a no-op activation when it IS running — race-safe, never a duplicate instance; // absolute /usr/bin//bin paths — launchd's PATH is minimal). An absent SwiftBar.app // backs off 5 min instead of churning. KeepAlive guards the LOOP process itself. // ───────────────────────────────────────────────────────────────────────────── /** Absolute path of the tray autostart LaunchAgent plist (IAPEER_LAUNCHAGENTS_DIR * isolates it under test/sandbox; else ~/Library/LaunchAgents). */ export function swiftBarAutostartPlistPath(env: NodeJS.ProcessEnv = process.env): string { return join(launchAgentsDir(env), `${SWIFTBAR_AUTOSTART_LABEL}.plist`) } /** Render the SwiftBar tray-host supervisor plist. PURE/deterministic (golden-testable). * Launch is `open -g -b `: LaunchServices activates the app correctly as a * menu-bar agent (`-g` = don't steal focus), and bundle-id targeting survives the app * moving. Carries the foundation ownership sentinel (inert `` launchd ignores). */ export function renderSwiftBarAutostartPlist(): string { // Single-line sh loop (see WATCHDOG SHAPE above): 5 s liveness poll, no-op when // SwiftBar runs, 5 min backoff when the app is not installed at all. const loop = 'while :; do ' + `if ! /usr/bin/pgrep -xq SwiftBar; then /usr/bin/open -g -b ${SWIFTBAR_BUNDLE_ID} || /bin/sleep 300; fi; ` + '/bin/sleep 5; done' return ` Label ${SWIFTBAR_AUTOSTART_LABEL} ${IAPEER_PLIST_OWNER_KEY} ProgramArguments /bin/sh -c ${loop} RunAtLoad KeepAlive ProcessType Background ` } export type AutostartState = | 'loaded' // bootstrapped now (was not loaded) | 'already-loaded' // job already in the gui domain → no-op | 'restarted' // plist changed → cycled (bootout+bootstrap) onto the new content | 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch a real plist / launchctl | 'failed' // launchctl bootstrap/cycle failed export interface EnsureAutostartResult { plistPath: string /** Whether the plist bytes changed (false = already current). */ wrote: boolean state: AutostartState detail?: string } /** * Idempotently register the SwiftBar login-autostart LaunchAgent (write the plist if its * bytes changed, then load/restart it). Safe to repeat — a changed plist is cycled, an * unloaded one is bootstrapped, an unchanged-and-loaded one is a no-op (never a dup). * SANDBOX FAIL-SAFE: under IAPEER_TEST_SANDBOX with no isolated IAPEER_LAUNCHAGENTS_DIR * it writes NOTHING (a leaked real autostart would outlive the test) and touches no * launchctl; with an isolated dir it writes there but still skips launchctl (host-global). */ export function ensureSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): EnsureAutostartResult { const plistPath = swiftBarAutostartPlistPath(env) const content = renderSwiftBarAutostartPlist() const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1' const realAgents = join(homedir(), 'Library', 'LaunchAgents') if (sandbox && plistPath.startsWith(`${realAgents}/`)) { return { plistPath, wrote: false, state: 'skipped-sandbox', detail: 'IAPEER_TEST_SANDBOX=1 without IAPEER_LAUNCHAGENTS_DIR — refusing to write a real LaunchAgents plist', } } mkdirSync(launchAgentsDir(env), { recursive: true }) let wrote = false if (!existsSync(plistPath) || readFileSync(plistPath, 'utf8') !== content) { // atomic write (tmp+rename): an interrupted direct write could leave a sentinel-less // stub that the ownership guard then reads as foreign. const tmp = `${plistPath}.tmp-${process.pid}` writeFileSync(tmp, content, { mode: 0o644 }) renameSync(tmp, plistPath) wrote = true } if (sandbox) return { plistPath, wrote, state: 'skipped-sandbox' } // Live: load / restart onto the plist now on disk. const bootstrap = (): EnsureAutostartResult => { const bs = bootstrapLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env) const state: AutostartState = bs.state === 'loaded' ? 'loaded' : bs.state === 'already-loaded' ? 'already-loaded' : 'failed' return { plistPath, wrote, state, detail: bs.detail } } if (!wrote) return bootstrap() const cyc = cycleLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env) if (cyc.state === 'restarted') return { plistPath, wrote, state: 'restarted' } if (cyc.state === 'not-loaded') return bootstrap() // first install: nothing to cycle → load it return { plistPath, wrote, state: 'failed', detail: cyc.detail } } export interface RemoveAutostartResult { plistPath: string removed: boolean state: 'removed' | 'not-present' | 'foreign' | 'skipped-sandbox' } /** * Tear down the SwiftBar login-autostart LaunchAgent (bootout the job, remove the plist) * — the symmetric counterpart of ensureSwiftBarAutostart, run on `tray uninstall`. Only * removes OUR plist (ownership sentinel present); a sentinel-less file at the label is * left untouched (`foreign`). Sandbox-safe. */ export function removeSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): RemoveAutostartResult { const plistPath = swiftBarAutostartPlistPath(env) const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1' const realAgents = join(homedir(), 'Library', 'LaunchAgents') if (sandbox && plistPath.startsWith(`${realAgents}/`)) return { plistPath, removed: false, state: 'skipped-sandbox' } if (!existsSync(plistPath)) return { plistPath, removed: false, state: 'not-present' } if (!isFoundationOwnedPlist(plistPath)) return { plistPath, removed: false, state: 'foreign' } bootoutLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, env) // unload the running job (sandbox-safe no-op) rmSync(plistPath, { force: true }) return { plistPath, removed: true, state: 'removed' } } /** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write * into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */ function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void { if (env.IAPEER_TEST_SANDBOX === '1' && resolveGlobalRoot(env) === join(homedir(), '.iapeer')) { throw new Error('refusing to install the tray plugin into the REAL ~/.iapeer under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT') } } export interface InstallTrayOptions { env?: NodeJS.ProcessEnv /** Install SwiftBar.app via brew when absent (owner-sanctioned). Default false. */ installApp?: boolean /** Launch + refresh SwiftBar after writing the plugin. Default false. */ launch?: boolean run?: Runner /** Injectable app-presence probe (tests). Default: swiftBarInstalled(). */ probeApp?: () => boolean } export interface InstallTrayResult { pluginFile: string pluginDir: string /** Whether the plugin file bytes changed (false = already current). */ wrote: boolean /** How the plugin dir was chosen. */ dir: 'existing-swiftbar' | 'dedicated-configured' | 'dedicated-only' app: 'present' | 'installed' | 'install-failed' | 'absent' appReason?: string launched: boolean /** Tray-host supervisor (autostart) registration outcome: set when activated * (launch && SwiftBar present) OR upgraded in place (our plist already on disk). */ autostart?: EnsureAutostartResult } /** * Idempotently install the tray plugin. Steps (each safe to repeat): * 1. install SwiftBar.app if absent && installApp (best-effort — a brew failure is * reported, not thrown; the plugin file still lands so it activates later). * 2. resolve the target dir: an already-configured SwiftBar PluginDirectory is * RESPECTED (install into the user's dir); otherwise the dedicated ~/.iapeer dir, * and — only when SwiftBar is present — point SwiftBar at it via defaults. * 3. write the plugin shim (skip if byte-identical; always ensure +x). * 4. launch + refresh SwiftBar when launch && SwiftBar present. */ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult { const env = opts.env ?? process.env const run = opts.run ?? defaultRun const probeApp = opts.probeApp ?? swiftBarInstalled assertTraySandboxIsolated(env) const binPath = iapeerBinPath(env) // 1 — app let app: InstallTrayResult['app'] = probeApp() ? 'present' : 'absent' let appReason: string | undefined if (app === 'absent' && opts.installApp) { const r = installSwiftBarApp(run) if (r.status === 0 || probeApp()) app = 'installed' else { app = 'install-failed' appReason = (r.stderr || r.stdout || `brew exit ${r.status}`).trim().slice(0, 300) } } const present = app === 'present' || app === 'installed' // 2 — target dir const existing = present ? readSwiftBarPluginDir(run) : undefined let pluginDir: string let dir: InstallTrayResult['dir'] if (existing && existsSync(existing)) { pluginDir = existing dir = 'existing-swiftbar' } else { pluginDir = dedicatedPluginDir(env) if (present) { setSwiftBarPluginDir(pluginDir, run) dir = 'dedicated-configured' } else { dir = 'dedicated-only' } } mkdirSync(pluginDir, { recursive: true }) // 3 — plugin file (idempotent write; always +x) const pluginFile = join(pluginDir, PLUGIN_BASENAME) const content = buildPluginShim(binPath) let wrote = false if (!existsSync(pluginFile) || readFileSync(pluginFile, 'utf8') !== content) { writeFileSync(pluginFile, content, { mode: 0o755 }) wrote = true } else { try { chmodSync(pluginFile, 0o755) } catch { /* best-effort */ } } // 4 — launch + refresh + register the tray-host supervisor (login autostart + // relaunch-on-death), so a reboot or a SwiftBar crash brings the tray back by itself. let launched = false let autostart: EnsureAutostartResult | undefined if (opts.launch && present) { dequarantineApp(run) launchSwiftBar(run) refreshSwiftBar(run) launched = true // Idempotent: the plist is byte-stable and the bootstrap is a no-op when loaded, so // re-activation never plants a duplicate autostart. autostart = ensureSwiftBarAutostart(env) } else if (existsSync(swiftBarAutostartPlistPath(env)) && isFoundationOwnedPlist(swiftBarAutostartPlistPath(env))) { // UPGRADE-IN-PLACE: this host opted into the autostart earlier (our plist is on // disk). The foundation `install` verb runs installTray with launch:false — without // this branch a deploy (`iapeer update` → install) would leave an OUTDATED agent // (e.g. the 0.4.82 one-shot RunAtLoad plist) registered forever. ensure() rewrites // the plist only when its bytes changed and re-cycles the job onto the new content. autostart = ensureSwiftBarAutostart(env) } return { pluginFile, pluginDir, wrote, dir, app, appReason, launched, autostart } } export interface UninstallTrayResult { removed: string[] /** SwiftBar was refreshed to drop the now-missing plugin. */ refreshed: boolean /** Login-autostart teardown outcome (the GUI-face activation is being removed). */ autostart: RemoveAutostartResult } /** * Remove the tray plugin file from EVERY dir it could live in (the dedicated dir and * a configured SwiftBar dir) and refresh SwiftBar. DELIBERATELY leaves SwiftBar.app * installed (the user may run other plugins) and NEVER touches the fleet — the daemon, * TUI and delivery keep running (acceptance criterion: GUI teardown ≠ fleet teardown). */ export function uninstallTray( opts: { env?: NodeJS.ProcessEnv; launch?: boolean; run?: Runner; probeApp?: () => boolean } = {}, ): UninstallTrayResult { const env = opts.env ?? process.env const run = opts.run ?? defaultRun assertTraySandboxIsolated(env) const present = (opts.probeApp ?? swiftBarInstalled)() const dirs = new Set([dedicatedPluginDir(env)]) if (present) { const existing = readSwiftBarPluginDir(run) if (existing) dirs.add(existing) } const removed: string[] = [] for (const d of dirs) { const f = join(d, PLUGIN_BASENAME) if (existsSync(f)) { rmSync(f, { force: true }) removed.push(f) } } let refreshed = false if (opts.launch && present) { refreshSwiftBar(run) refreshed = true } // Tear down the login-autostart LaunchAgent too — removing the GUI face should not // leave a reboot re-launching SwiftBar. Never touches the fleet (bootout of our own // com.agfpd.* label only). const autostart = removeSwiftBarAutostart(env) return { removed, refreshed, autostart } } /** * Open a system Terminal attached to a peer WITHOUT SwiftBar's `terminal=true`. SwiftBar * v2's Terminal.app launch drives a `Cmd-T` keystroke via System Events, which silently * no-ops unless SwiftBar holds Accessibility permission — so tray attach clicks appeared * dead even after the Automation→Terminal grant. Instead: write a per-peer `.command` * launcher and `open` it. `open .command` hands the file to Terminal.app (its * default handler) and runs it — a document-open, needing NO Automation/Accessibility * TCC. This is the tray's terminal handoff (docs/15: attach is client-side). The peer * name is validated (it is interpolated into a shell script). Returns the launcher path. */ export function trayAttachTerm(opts: { env?: NodeJS.ProcessEnv personality: string runtime?: string run?: Runner }): { cmdFile: string } { const env = opts.env ?? process.env const run = opts.run ?? defaultRun const p = opts.personality if (!/^[a-z][a-z0-9-]*$/.test(p)) throw new Error(`invalid peer name "${p}"`) const rt = opts.runtime && /^[a-z][a-z0-9-]*$/.test(opts.runtime) ? opts.runtime : undefined const binPath = iapeerBinPath(env) const dir = join(resolveGlobalRoot(env), 'tray') mkdirSync(dir, { recursive: true }) const cmdFile = join(dir, `attach-${p}.command`) writeFileSync(cmdFile, `#!/bin/bash\nexec "${binPath}" attach ${p}${rt ? ` ${rt}` : ''}\n`, { mode: 0o755 }) run('open', [cmdFile]) return { cmdFile } } export interface TrayStatus { daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string } swiftbar: { installed: boolean pluginDir?: string /** GROUND TRUTH of the menu-bar icon: AppKit persists NSStatusItem visibility per * autosaveName in SwiftBar's defaults (`NSStatusItem VisibleCC `). false = * the icon is hidden RIGHT NOW (whatever hid it — a mid-chunk decode nil on an old * binary, a ⌘-drag removal); undefined = key absent / SwiftBar not installed. The * first thing to read when "the icon is gone" is reported — no screen needed. */ iconVisible?: boolean } plugin: { installed: boolean; path?: string } /** Tray-host supervisor LaunchAgent: registered (our plist present) + its path. */ autostart: { registered: boolean; path?: string } } /** Read-only diagnostics — never repairs anything. */ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeApp?: () => boolean } = {}): TrayStatus { const env = opts.env ?? process.env const run = opts.run ?? defaultRun const addr = resolveFleetAddress({ env }) const installed = (opts.probeApp ?? swiftBarInstalled)() const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[] const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f)) const autostartPath = swiftBarAutostartPlistPath(env) const autostartOurs = existsSync(autostartPath) && isFoundationOwnedPlist(autostartPath) let iconVisible: boolean | undefined if (installed) { const r = run('defaults', ['read', SWIFTBAR_DOMAIN, `NSStatusItem VisibleCC ${PLUGIN_BASENAME}`]) if (r.status === 0) iconVisible = r.stdout.trim() === '1' } return { daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp }, swiftbar: { installed, pluginDir: cfgDir, iconVisible }, plugin: { installed: Boolean(found), path: found }, autostart: { registered: autostartOurs, path: autostartOurs ? autostartPath : undefined }, } }