@agfpd/iapeer 0.4.80 → 0.4.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/16-tray.md +23 -3
- package/docs/ru/16-tray.md +22 -3
- package/package.json +1 -1
- package/src/cli/index.ts +6 -1
- package/src/launch/launchd.ts +51 -0
- package/src/transport/index.ts +29 -12
- package/src/tray/install.ts +180 -4
package/docs/16-tray.md
CHANGED
|
@@ -80,12 +80,12 @@ whole dashboard twice.
|
|
|
80
80
|
|
|
81
81
|
| Verb | What it does |
|
|
82
82
|
|---|---|
|
|
83
|
-
| `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh
|
|
84
|
-
| `iapeer tray uninstall` | Remove the plugin file
|
|
83
|
+
| `iapeer tray install [--plugin-only]` | Install SwiftBar.app when absent (owner-sanctioned), point it at the plugin dir, write the plugin, launch + refresh, **and register the login-autostart LaunchAgent**. `--plugin-only` writes just the plugin file (no app, no launch, no autostart — what the foundation install does). Idempotent. |
|
|
84
|
+
| `iapeer tray uninstall` | Remove the plugin file, refresh SwiftBar, **and tear down the login-autostart LaunchAgent**. **Never touches the fleet** — the daemon, TUI and delivery keep running; SwiftBar.app is left installed (it may host other plugins). |
|
|
85
85
|
| `iapeer tray render [--stream]` | Print the SwiftBar plugin output. `--stream` is the streamable loop the plugin runs; the one-shot form is the poll fallback and the test surface. |
|
|
86
86
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST a fleet command (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — the menu's lifecycle actions call this. |
|
|
87
87
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Resolve a pending human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` over the same unix-first fleet client. The menu's Allow/Deny items call these; the single-queue invariant means the resolution is seen by every channel (CLI, telegram). |
|
|
88
|
-
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present
|
|
88
|
+
| `iapeer tray status` | Read-only: is the fleet API up (from `router.json`), is SwiftBar installed and where is its plugin dir, is the plugin file present, **is the login autostart registered**. Repairs nothing. |
|
|
89
89
|
|
|
90
90
|
## How it is wired
|
|
91
91
|
|
|
@@ -102,6 +102,26 @@ plugin is installed into the user's own dir); otherwise a dedicated
|
|
|
102
102
|
reads on launch (it is not sandboxed and stores no security-scoped bookmark), so the
|
|
103
103
|
folder picker is skipped.
|
|
104
104
|
|
|
105
|
+
Login autostart: activating the tray registers a user LaunchAgent
|
|
106
|
+
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad, no KeepAlive) whose
|
|
107
|
+
`ProgramArguments` are `open -g -b com.ameba.SwiftBar` — so SwiftBar (and with it the
|
|
108
|
+
fleet dashboard + approval badge) comes back on its own after a reboot, with no manual
|
|
109
|
+
`open -a SwiftBar`. A **LaunchAgent** is used deliberately, not a System Events "Login
|
|
110
|
+
Item" nor SMAppService: a Login Item needs the invoking process to hold Automation TCC
|
|
111
|
+
over System Events (attributed to whatever parent runs the verb — fragile in a headless /
|
|
112
|
+
`iapeer update` context), and SMAppService can only register a helper the *target* app
|
|
113
|
+
ships (we cannot register the third-party SwiftBar with it from a CLI). The LaunchAgent
|
|
114
|
+
needs no TCC and reuses the foundation's idempotent plist lifecycle: the plist is
|
|
115
|
+
byte-stable (write-if-changed), carries the `com.iapeer.managed` ownership sentinel, and
|
|
116
|
+
is bootstrapped/cycled undead-safe — so re-activation never plants a duplicate. It is
|
|
117
|
+
**RunAtLoad-only** (fire once at login) rather than KeepAlive, because the `open` process
|
|
118
|
+
exits immediately once SwiftBar is up (KeepAlive would respawn-storm it) and supervising
|
|
119
|
+
SwiftBar itself would fight the user quitting it. `tray uninstall` boots it out and
|
|
120
|
+
removes the plist (only our own sentinel-marked file — a foreign plist at the label is
|
|
121
|
+
left untouched). The label lives in the foundation's own `com.agfpd.*` namespace, never
|
|
122
|
+
`com.iapeer.<personality>` (the persistent-peer fleet), so the H4 launchd-owned
|
|
123
|
+
sweep-guard can never mistake it for a peer.
|
|
124
|
+
|
|
105
125
|
Auth follows the Fleet API: on an open-local host no bearer is needed; when the daemon is
|
|
106
126
|
configured with `IAPEER_BEARER_TOKEN`, the plugin's environment must carry it too.
|
|
107
127
|
|
package/docs/ru/16-tray.md
CHANGED
|
@@ -76,12 +76,12 @@ SwiftBar.app, если его нет.
|
|
|
76
76
|
|
|
77
77
|
| Verb | Что делает |
|
|
78
78
|
|---|---|
|
|
79
|
-
| `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит +
|
|
80
|
-
| `iapeer tray uninstall` | Удалит файл
|
|
79
|
+
| `iapeer tray install [--plugin-only]` | Доустановит SwiftBar.app, если его нет (санкция владельца), нацелит его на каталог плагинов, запишет плагин, запустит + обновит **и зарегистрирует LaunchAgent автозапуска при логине**. `--plugin-only` пишет только файл плагина (без приложения, запуска и автозапуска — так делает foundation-install). Идемпотентно. |
|
|
80
|
+
| `iapeer tray uninstall` | Удалит файл плагина, обновит SwiftBar **и снесёт LaunchAgent автозапуска**. **Флот не трогает** — демон, TUI и доставка живут; SwiftBar.app остаётся (может держать другие плагины). |
|
|
81
81
|
| `iapeer tray render [--stream]` | Печатает вывод плагина SwiftBar. `--stream` — streamable-цикл, который крутит плагин; одноразовая форма — поллинг-фолбэк и тест-поверхность. |
|
|
82
82
|
| `iapeer tray cmd <command> <peer> [runtime]` | POST fleet-команды (`wake`/`stop`/`start`/`new`/`refresh`/`interrupt`/`compact`) — lifecycle-пункты меню зовут это. |
|
|
83
83
|
| `iapeer tray approve <id>` · `iapeer tray deny <id> [reason]` | Резолв ожидающего human-approval (docs/17) — `POST /fleet/v1/approvals/<id>/(approve\|deny)` через тот же unix-first fleet-клиент. Пункты Allow/Deny меню зовут это; инвариант единой очереди — резолв виден каждому каналу (CLI, telegram). |
|
|
84
|
-
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл
|
|
84
|
+
| `iapeer tray status` | Только чтение: жив ли fleet API (из `router.json`), установлен ли SwiftBar и где его каталог плагинов, на месте ли файл плагина, **зарегистрирован ли автозапуск**. Ничего не чинит. |
|
|
85
85
|
|
|
86
86
|
## Как устроено
|
|
87
87
|
|
|
@@ -97,6 +97,25 @@ SwiftBar.app, если его нет.
|
|
|
97
97
|
который SwiftBar читает при запуске (он не sandboxed и не хранит security-scoped bookmark),
|
|
98
98
|
поэтому окно выбора папки пропускается.
|
|
99
99
|
|
|
100
|
+
Автозапуск при логине: активация трея регистрирует пользовательский LaunchAgent
|
|
101
|
+
`~/Library/LaunchAgents/com.agfpd.iapeer.tray.plist` (RunAtLoad, без KeepAlive), чьи
|
|
102
|
+
`ProgramArguments` — `open -g -b com.ameba.SwiftBar`, так что SwiftBar (а с ним fleet-дашборд
|
|
103
|
+
и бейдж аппрувов) сам поднимается после перезагрузки, без ручного `open -a SwiftBar`.
|
|
104
|
+
**LaunchAgent** выбран намеренно, не «Login Item» через System Events и не SMAppService: Login
|
|
105
|
+
Item требует, чтобы вызывающий процесс держал Automation-TCC на System Events (грант
|
|
106
|
+
атрибутируется тому родителю, что запустил verb — хрупко в headless / `iapeer update`
|
|
107
|
+
контексте), а SMAppService умеет регистрировать только хелпер, который несёт *само* целевое
|
|
108
|
+
приложение (сторонний SwiftBar так из CLI не зарегистрировать). LaunchAgent не требует TCC и
|
|
109
|
+
переиспользует идемпотентный plist-жизненный цикл foundation: plist байт-стабилен (пишется
|
|
110
|
+
только при изменении), несёт sentinel владения `com.iapeer.managed` и bootstrap'ится/
|
|
111
|
+
пересоздаётся undead-safe — повторная активация не плодит дубль. Он **RunAtLoad-only** (сработать
|
|
112
|
+
один раз при логине), а не KeepAlive, потому что процесс `open` завершается сразу, как SwiftBar
|
|
113
|
+
поднят (KeepAlive устроил бы respawn-шторм), а супервизия самого SwiftBar боролась бы с
|
|
114
|
+
пользователем, который его закрыл. `tray uninstall` делает bootout и удаляет plist (только наш
|
|
115
|
+
файл с sentinel — чужой plist на этом лейбле не трогается). Лейбл живёт в собственном namespace
|
|
116
|
+
foundation `com.agfpd.*`, никогда не `com.iapeer.<personality>` (флот persistent-пиров), поэтому
|
|
117
|
+
H4-страж launchd-owned пиров не может принять его за пира.
|
|
118
|
+
|
|
100
119
|
Авторизация — как у Fleet API: на open-local хосте bearer не нужен; если демон настроен с
|
|
101
120
|
`IAPEER_BEARER_TOKEN`, окружение плагина тоже должно его нести.
|
|
102
121
|
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -2581,6 +2581,9 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2581
2581
|
` plugin dir: ${r.pluginDir} (${r.dir})\n` +
|
|
2582
2582
|
` SwiftBar: ${r.app}${r.appReason ? ` — ${r.appReason}` : ''}\n` +
|
|
2583
2583
|
(r.launched ? ' launched + refreshed SwiftBar\n' : '') +
|
|
2584
|
+
(r.autostart
|
|
2585
|
+
? ` login autostart: ${r.autostart.state}${r.autostart.detail ? ` — ${r.autostart.detail}` : ''} (${r.autostart.plistPath})\n`
|
|
2586
|
+
: '') +
|
|
2584
2587
|
(r.app === 'absent' || r.app === 'install-failed'
|
|
2585
2588
|
? ' (SwiftBar not active — run `iapeer tray install` to install it, then it appears in the menu bar)\n'
|
|
2586
2589
|
: ''),
|
|
@@ -2592,6 +2595,7 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2592
2595
|
out(
|
|
2593
2596
|
(r.removed.length ? `removed:\n${r.removed.map(f => ` ${f}`).join('\n')}\n` : 'no tray plugin file found\n') +
|
|
2594
2597
|
(r.refreshed ? ' refreshed SwiftBar\n' : '') +
|
|
2598
|
+
` login autostart: ${r.autostart.state}${r.autostart.removed ? ` (${r.autostart.plistPath})` : ''}\n` +
|
|
2595
2599
|
' fleet untouched (daemon / TUI / delivery unaffected)\n',
|
|
2596
2600
|
)
|
|
2597
2601
|
return 0
|
|
@@ -2603,7 +2607,8 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
|
|
|
2603
2607
|
` sock: ${s.daemon.sock ?? '—'}\n` +
|
|
2604
2608
|
` tcp: ${s.daemon.tcp ?? '—'}\n` +
|
|
2605
2609
|
`SwiftBar: ${s.swiftbar.installed ? `installed (plugin dir: ${s.swiftbar.pluginDir ?? 'unset'})` : 'not installed'}\n` +
|
|
2606
|
-
`plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n
|
|
2610
|
+
`plugin: ${s.plugin.installed ? s.plugin.path : 'not installed'}\n` +
|
|
2611
|
+
`login autostart: ${s.autostart.registered ? s.autostart.path : 'not registered'}\n`,
|
|
2607
2612
|
)
|
|
2608
2613
|
return 0
|
|
2609
2614
|
}
|
package/src/launch/launchd.ts
CHANGED
|
@@ -452,6 +452,57 @@ export function cycleLaunchdJob(
|
|
|
452
452
|
})
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Idempotently ensure ANY launchd job is LOADED in the gui domain — bootstrap when
|
|
457
|
+
* absent, no-op when already loaded. The generic sibling of `bootstrapDaemon` for a
|
|
458
|
+
* caller-supplied label/plist pair (e.g. the tray SwiftBar-autostart LaunchAgent).
|
|
459
|
+
* The CALLER owns any ownership/fleet guard; this primitive only loads the concrete
|
|
460
|
+
* label/plist. Composed on bootstrapJobCore (undead-job-safe wait-for-gone + backoff).
|
|
461
|
+
* SANDBOX FAIL-SAFE: never calls launchctl under IAPEER_TEST_SANDBOX (`bootstrap
|
|
462
|
+
* gui/<uid>` is host-global regardless of IAPEER_ROOT) — consulting BOTH the passed and
|
|
463
|
+
* the process env, the same fail-closed rule as launchctlBootstrap.
|
|
464
|
+
*/
|
|
465
|
+
export function bootstrapLaunchdJob(
|
|
466
|
+
label: string,
|
|
467
|
+
plistPath: string,
|
|
468
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
469
|
+
): BootstrapResult {
|
|
470
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
|
|
471
|
+
return { state: 'skipped-sandbox', label, detail: 'IAPEER_TEST_SANDBOX=1 — not loading a real launchd job' }
|
|
472
|
+
}
|
|
473
|
+
const uid = currentUid()
|
|
474
|
+
const core = bootstrapJobCore(uid, label, plistPath, {
|
|
475
|
+
run: args => {
|
|
476
|
+
const r = spawnSync('launchctl', args, { encoding: 'utf8' })
|
|
477
|
+
return { status: r.status, stderr: r.stderr ?? '' }
|
|
478
|
+
},
|
|
479
|
+
sleepMs: ms => spawnSync('sleep', [String(ms / 1000)]),
|
|
480
|
+
})
|
|
481
|
+
return core.state === 'failed' ? { state: 'failed', label, detail: core.detail } : { state: core.state, label }
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface BootoutResult {
|
|
485
|
+
state: 'booted-out' | 'not-loaded' | 'skipped-sandbox' | 'failed'
|
|
486
|
+
detail?: string
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Unload a launchd job from the gui domain (`launchctl bootout gui/<uid>/<label>`) —
|
|
491
|
+
* the teardown primitive (used to remove the tray SwiftBar-autostart LaunchAgent on
|
|
492
|
+
* `tray uninstall`). Idempotent: an un-loaded label is `not-loaded` (no-op). The CALLER
|
|
493
|
+
* removes the plist file (this only unloads the running job). Sandbox-safe: never calls
|
|
494
|
+
* launchctl under IAPEER_TEST_SANDBOX.
|
|
495
|
+
*/
|
|
496
|
+
export function bootoutLaunchdJob(label: string, env: NodeJS.ProcessEnv = process.env): BootoutResult {
|
|
497
|
+
if (env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1') {
|
|
498
|
+
return { state: 'skipped-sandbox' }
|
|
499
|
+
}
|
|
500
|
+
const uid = currentUid()
|
|
501
|
+
if (!isLaunchdLoaded(label, uid)) return { state: 'not-loaded' }
|
|
502
|
+
const r = spawnSync('launchctl', ['bootout', `gui/${uid}/${label}`], { encoding: 'utf8' })
|
|
503
|
+
return r.status === 0 ? { state: 'booted-out' } : { state: 'failed', detail: (r.stderr ?? '').trim() || `exit ${r.status}` }
|
|
504
|
+
}
|
|
505
|
+
|
|
455
506
|
/**
|
|
456
507
|
* AUTO-bootstrap a freshly-provisioned foundation plist into the gui domain
|
|
457
508
|
* (`launchctl bootstrap gui/<uid> <plist>`) — the "load it now, don't write-and-wait
|
package/src/transport/index.ts
CHANGED
|
@@ -307,7 +307,7 @@ async function deliverViaHost(
|
|
|
307
307
|
// a short grace covers it. The receiver's own assistant/tool turn never reproduces the
|
|
308
308
|
// `<iap from-personality=…>` wrapper, so it cannot forge a confirm. No such record within the grace →
|
|
309
309
|
// false-FAIL (the sender retries) — NOT a false-OK (silent loss the contract forbids).
|
|
310
|
-
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload))
|
|
310
|
+
const confirm = seam.confirmLanded ?? ((payload: string) => transcriptCarriesEnvelope(baseline, payload, { env }))
|
|
311
311
|
const sleep = seam.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
|
|
312
312
|
const graceMs = confirmGraceMs()
|
|
313
313
|
const graceDeadline = monotonicMs() + graceMs
|
|
@@ -579,20 +579,37 @@ function jsonStringValuesContain(value: unknown, needle: string): boolean {
|
|
|
579
579
|
* the full envelope is UNFORGEABLE by a concurrent turn — no false-OK. Because only bytes PAST the
|
|
580
580
|
* baseline offset are read, a pre-existing copy of an identical earlier message cannot pass either.
|
|
581
581
|
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
587
|
-
*
|
|
582
|
+
* Scans each baseline file from its captured offset AND any session file that APPEARS AFTER the baseline
|
|
583
|
+
* from offset 0 — the post-baseline pickup is load-bearing. A FRESH session (self-fresh/eager-fresh)
|
|
584
|
+
* writes its NEW jsonl LAZILY on the first delivered turn, so the confirming record for the FIRST delivery
|
|
585
|
+
* into that session lands in a file the baseline never captured (the fresh jsonl is born ~a beat AFTER the
|
|
586
|
+
* pre-deliver baseline snapshot). Iterating baseline-only made EVERY such first-delivery a false-FAIL: the
|
|
587
|
+
* dead OLD session file gets scanned, the live NEW one is ignored, and the confirm reported "not delivered"
|
|
588
|
+
* for a message the session had actually accepted + processed (verified live 2026-07-10: telegram-arthur →
|
|
589
|
+
* mrmechanic ok=false, yet the envelope stood in the fresh jsonl at +0.3s, within the grace). A
|
|
590
|
+
* post-baseline file is BRAND-NEW for this cwd, so reading it from 0 cannot match a pre-existing copy
|
|
591
|
+
* (nothing pre-existed), and the full `<iap from-personality=…>` envelope is unforgeable by the receiver's
|
|
592
|
+
* own assistant/tool turn — the SAME false-OK safety `compactTranscriptHasDone` already relies on. In
|
|
593
|
+
* production this loop runs ONLY for claude (codex confirms by the socket-ack BEFORE reaching here), so the
|
|
594
|
+
* per-poll re-list is a single cheap readdirSync of one project dir — never a recursive `~/.codex/sessions`
|
|
595
|
+
* walk. `env` scopes the re-list under a test HOME (default process.env).
|
|
588
596
|
*/
|
|
589
|
-
export function transcriptCarriesEnvelope(
|
|
597
|
+
export function transcriptCarriesEnvelope(
|
|
598
|
+
baseline: CompactDoneBaseline,
|
|
599
|
+
envelope: string,
|
|
600
|
+
opts: { env?: NodeJS.ProcessEnv } = {},
|
|
601
|
+
): boolean {
|
|
590
602
|
const needle = envelope.replace(/\r/g, '')
|
|
591
603
|
if (!needle) return false
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
604
|
+
// Baseline files at their pre-deliver offset; a file NOT in the baseline (born after it) is read from 0.
|
|
605
|
+
const offsets = new Map(baseline.files.map(f => [f.path, f.size]))
|
|
606
|
+
for (const path of compactCandidateFiles(baseline.runtime, baseline.cwd, opts.env)) {
|
|
607
|
+
if (!offsets.has(path)) offsets.set(path, 0)
|
|
608
|
+
}
|
|
609
|
+
for (const [path, offset] of offsets) {
|
|
610
|
+
// Read ONLY the tail past the offset — a full readFileSync here re-read the ENTIRE transcript
|
|
611
|
+
// (hundreds of MB for a long-running peer) on every ~100ms confirm-poll, stalling the single-threaded
|
|
612
|
+
// router event loop for the whole fleet.
|
|
596
613
|
const tail = readFileTailFrom(path, offset)
|
|
597
614
|
if (!tail) continue
|
|
598
615
|
for (const line of tail.split(/\r?\n/)) {
|
package/src/tray/install.ts
CHANGED
|
@@ -21,12 +21,20 @@
|
|
|
21
21
|
// so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
|
|
22
22
|
// open) goes through an injectable Runner so tests stay hermetic.
|
|
23
23
|
|
|
24
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs'
|
|
25
25
|
import { spawnSync } from 'child_process'
|
|
26
26
|
import { homedir } from 'os'
|
|
27
27
|
import { join } from 'path'
|
|
28
28
|
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
29
29
|
import { iapeerBinPath } from '../install/index.ts'
|
|
30
|
+
import {
|
|
31
|
+
IAPEER_PLIST_OWNER_KEY,
|
|
32
|
+
bootoutLaunchdJob,
|
|
33
|
+
bootstrapLaunchdJob,
|
|
34
|
+
cycleLaunchdJob,
|
|
35
|
+
isFoundationOwnedPlist,
|
|
36
|
+
launchAgentsDir,
|
|
37
|
+
} from '../launch/launchd.ts'
|
|
30
38
|
import { resolveFleetAddress } from './client.ts'
|
|
31
39
|
|
|
32
40
|
/** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
|
|
@@ -34,6 +42,16 @@ import { resolveFleetAddress } from './client.ts'
|
|
|
34
42
|
* ignored. The basename is stable — install rewrites the same file (idempotent). */
|
|
35
43
|
export const PLUGIN_BASENAME = 'iapeer.10s.sh'
|
|
36
44
|
const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
|
|
45
|
+
// SwiftBar's `defaults` domain IS its bundle identifier — reuse it for the autostart
|
|
46
|
+
// LaunchAgent's `open -b <bundleid>` (bundle-id launch survives the app moving/renaming
|
|
47
|
+
// between /Applications and ~/Applications, unlike a baked absolute path or `-a <name>`).
|
|
48
|
+
const SWIFTBAR_BUNDLE_ID = SWIFTBAR_DOMAIN
|
|
49
|
+
// The login-autostart LaunchAgent's label. Foundation namespace (`com.agfpd.*` = the
|
|
50
|
+
// foundation's own jobs), DELIBERATELY not `com.iapeer.<personality>` — that namespace
|
|
51
|
+
// is the persistent-peer fleet keyed on personality, and the tray autostart is neither a
|
|
52
|
+
// peer nor daemon-managed. Distinct label ⇒ it can never be mistaken for a peer plist by
|
|
53
|
+
// the H4 launchd-owned sweep-guard.
|
|
54
|
+
const SWIFTBAR_AUTOSTART_LABEL = 'com.agfpd.iapeer.tray'
|
|
37
55
|
const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
|
|
38
56
|
|
|
39
57
|
export type RunResult = { status: number | null; stdout: string; stderr: string }
|
|
@@ -114,6 +132,147 @@ export function buildPluginShim(binPath: string): string {
|
|
|
114
132
|
].join('\n')
|
|
115
133
|
}
|
|
116
134
|
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Login autostart — a RunAtLoad LaunchAgent that brings SwiftBar up at login, so the
|
|
137
|
+
// fleet dashboard + approval badge survive a reboot without a manual `open -a SwiftBar`.
|
|
138
|
+
//
|
|
139
|
+
// MECHANISM CHOICE (owner-relevant): a user LaunchAgent, not a System Events "Login
|
|
140
|
+
// Item" and not SMAppService.
|
|
141
|
+
// • System Events Login Item (osascript `make login item`) — the manual stop-gap —
|
|
142
|
+
// requires the INVOKING process to hold Automation TCC over System Events; that
|
|
143
|
+
// grant is attributed to whatever parent runs the activation verb (Terminal / the
|
|
144
|
+
// daemon / an onboard run), so it silently no-ops or prompts in a headless / update
|
|
145
|
+
// context. A LaunchAgent needs no TCC at all — pure filesystem + launchctl.
|
|
146
|
+
// • SMAppService (macOS 13+) is the modern path, but it registers a helper the TARGET
|
|
147
|
+
// app ships — we cannot register a THIRD-PARTY GUI app (SwiftBar) with it from a CLI.
|
|
148
|
+
// • The LaunchAgent reuses the foundation's proven idempotent plist lifecycle
|
|
149
|
+
// (write-if-changed + undead-safe bootstrap/cycle, sandbox-guarded) and carries the
|
|
150
|
+
// ownership sentinel so uninstall can recognize + remove exactly our artifact.
|
|
151
|
+
//
|
|
152
|
+
// RunAtLoad-ONLY, no KeepAlive: the ProgramArguments launch SwiftBar via `open` (which
|
|
153
|
+
// exits immediately once SwiftBar is up), so KeepAlive would respawn-storm `open` every
|
|
154
|
+
// throttle window. A login-autostart only needs to fire once per session; keeping
|
|
155
|
+
// SwiftBar itself supervised (relaunch-on-quit) would fight the user quitting it and
|
|
156
|
+
// duplicate SwiftBar's own launch-at-login — out of scope for "start it at login".
|
|
157
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
/** Absolute path of the tray autostart LaunchAgent plist (IAPEER_LAUNCHAGENTS_DIR
|
|
160
|
+
* isolates it under test/sandbox; else ~/Library/LaunchAgents). */
|
|
161
|
+
export function swiftBarAutostartPlistPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
162
|
+
return join(launchAgentsDir(env), `${SWIFTBAR_AUTOSTART_LABEL}.plist`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Render the RunAtLoad SwiftBar-autostart plist. PURE/deterministic (golden-testable).
|
|
166
|
+
* Launch is `open -g -b <bundleid>`: LaunchServices activates the app correctly as a
|
|
167
|
+
* menu-bar agent (`-g` = don't steal focus), and bundle-id targeting survives the app
|
|
168
|
+
* moving. Carries the foundation ownership sentinel (inert `<key>` launchd ignores). */
|
|
169
|
+
export function renderSwiftBarAutostartPlist(): string {
|
|
170
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
171
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
172
|
+
<plist version="1.0">
|
|
173
|
+
<dict>
|
|
174
|
+
<key>Label</key>
|
|
175
|
+
<string>${SWIFTBAR_AUTOSTART_LABEL}</string>
|
|
176
|
+
<key>${IAPEER_PLIST_OWNER_KEY}</key>
|
|
177
|
+
<true/>
|
|
178
|
+
<key>ProgramArguments</key>
|
|
179
|
+
<array>
|
|
180
|
+
<string>/usr/bin/open</string>
|
|
181
|
+
<string>-g</string>
|
|
182
|
+
<string>-b</string>
|
|
183
|
+
<string>${SWIFTBAR_BUNDLE_ID}</string>
|
|
184
|
+
</array>
|
|
185
|
+
<key>RunAtLoad</key>
|
|
186
|
+
<true/>
|
|
187
|
+
</dict>
|
|
188
|
+
</plist>
|
|
189
|
+
`
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export type AutostartState =
|
|
193
|
+
| 'loaded' // bootstrapped now (was not loaded)
|
|
194
|
+
| 'already-loaded' // job already in the gui domain → no-op
|
|
195
|
+
| 'restarted' // plist changed → cycled (bootout+bootstrap) onto the new content
|
|
196
|
+
| 'skipped-sandbox' // IAPEER_TEST_SANDBOX=1 → never touch a real plist / launchctl
|
|
197
|
+
| 'failed' // launchctl bootstrap/cycle failed
|
|
198
|
+
export interface EnsureAutostartResult {
|
|
199
|
+
plistPath: string
|
|
200
|
+
/** Whether the plist bytes changed (false = already current). */
|
|
201
|
+
wrote: boolean
|
|
202
|
+
state: AutostartState
|
|
203
|
+
detail?: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Idempotently register the SwiftBar login-autostart LaunchAgent (write the plist if its
|
|
208
|
+
* bytes changed, then load/restart it). Safe to repeat — a changed plist is cycled, an
|
|
209
|
+
* unloaded one is bootstrapped, an unchanged-and-loaded one is a no-op (never a dup).
|
|
210
|
+
* SANDBOX FAIL-SAFE: under IAPEER_TEST_SANDBOX with no isolated IAPEER_LAUNCHAGENTS_DIR
|
|
211
|
+
* it writes NOTHING (a leaked real autostart would outlive the test) and touches no
|
|
212
|
+
* launchctl; with an isolated dir it writes there but still skips launchctl (host-global).
|
|
213
|
+
*/
|
|
214
|
+
export function ensureSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): EnsureAutostartResult {
|
|
215
|
+
const plistPath = swiftBarAutostartPlistPath(env)
|
|
216
|
+
const content = renderSwiftBarAutostartPlist()
|
|
217
|
+
const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
|
|
218
|
+
const realAgents = join(homedir(), 'Library', 'LaunchAgents')
|
|
219
|
+
if (sandbox && plistPath.startsWith(`${realAgents}/`)) {
|
|
220
|
+
return {
|
|
221
|
+
plistPath,
|
|
222
|
+
wrote: false,
|
|
223
|
+
state: 'skipped-sandbox',
|
|
224
|
+
detail: 'IAPEER_TEST_SANDBOX=1 without IAPEER_LAUNCHAGENTS_DIR — refusing to write a real LaunchAgents plist',
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
mkdirSync(launchAgentsDir(env), { recursive: true })
|
|
228
|
+
let wrote = false
|
|
229
|
+
if (!existsSync(plistPath) || readFileSync(plistPath, 'utf8') !== content) {
|
|
230
|
+
// atomic write (tmp+rename): an interrupted direct write could leave a sentinel-less
|
|
231
|
+
// stub that the ownership guard then reads as foreign.
|
|
232
|
+
const tmp = `${plistPath}.tmp-${process.pid}`
|
|
233
|
+
writeFileSync(tmp, content, { mode: 0o644 })
|
|
234
|
+
renameSync(tmp, plistPath)
|
|
235
|
+
wrote = true
|
|
236
|
+
}
|
|
237
|
+
if (sandbox) return { plistPath, wrote, state: 'skipped-sandbox' }
|
|
238
|
+
// Live: load / restart onto the plist now on disk.
|
|
239
|
+
const bootstrap = (): EnsureAutostartResult => {
|
|
240
|
+
const bs = bootstrapLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env)
|
|
241
|
+
const state: AutostartState =
|
|
242
|
+
bs.state === 'loaded' ? 'loaded' : bs.state === 'already-loaded' ? 'already-loaded' : 'failed'
|
|
243
|
+
return { plistPath, wrote, state, detail: bs.detail }
|
|
244
|
+
}
|
|
245
|
+
if (!wrote) return bootstrap()
|
|
246
|
+
const cyc = cycleLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, plistPath, env)
|
|
247
|
+
if (cyc.state === 'restarted') return { plistPath, wrote, state: 'restarted' }
|
|
248
|
+
if (cyc.state === 'not-loaded') return bootstrap() // first install: nothing to cycle → load it
|
|
249
|
+
return { plistPath, wrote, state: 'failed', detail: cyc.detail }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export interface RemoveAutostartResult {
|
|
253
|
+
plistPath: string
|
|
254
|
+
removed: boolean
|
|
255
|
+
state: 'removed' | 'not-present' | 'foreign' | 'skipped-sandbox'
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Tear down the SwiftBar login-autostart LaunchAgent (bootout the job, remove the plist)
|
|
260
|
+
* — the symmetric counterpart of ensureSwiftBarAutostart, run on `tray uninstall`. Only
|
|
261
|
+
* removes OUR plist (ownership sentinel present); a sentinel-less file at the label is
|
|
262
|
+
* left untouched (`foreign`). Sandbox-safe.
|
|
263
|
+
*/
|
|
264
|
+
export function removeSwiftBarAutostart(env: NodeJS.ProcessEnv = process.env): RemoveAutostartResult {
|
|
265
|
+
const plistPath = swiftBarAutostartPlistPath(env)
|
|
266
|
+
const sandbox = env.IAPEER_TEST_SANDBOX === '1' || process.env.IAPEER_TEST_SANDBOX === '1'
|
|
267
|
+
const realAgents = join(homedir(), 'Library', 'LaunchAgents')
|
|
268
|
+
if (sandbox && plistPath.startsWith(`${realAgents}/`)) return { plistPath, removed: false, state: 'skipped-sandbox' }
|
|
269
|
+
if (!existsSync(plistPath)) return { plistPath, removed: false, state: 'not-present' }
|
|
270
|
+
if (!isFoundationOwnedPlist(plistPath)) return { plistPath, removed: false, state: 'foreign' }
|
|
271
|
+
bootoutLaunchdJob(SWIFTBAR_AUTOSTART_LABEL, env) // unload the running job (sandbox-safe no-op)
|
|
272
|
+
rmSync(plistPath, { force: true })
|
|
273
|
+
return { plistPath, removed: true, state: 'removed' }
|
|
274
|
+
}
|
|
275
|
+
|
|
117
276
|
/** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
|
|
118
277
|
* into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
|
|
119
278
|
function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
|
|
@@ -143,6 +302,8 @@ export interface InstallTrayResult {
|
|
|
143
302
|
app: 'present' | 'installed' | 'install-failed' | 'absent'
|
|
144
303
|
appReason?: string
|
|
145
304
|
launched: boolean
|
|
305
|
+
/** Login-autostart registration outcome (only when launched + SwiftBar present). */
|
|
306
|
+
autostart?: EnsureAutostartResult
|
|
146
307
|
}
|
|
147
308
|
|
|
148
309
|
/**
|
|
@@ -208,22 +369,28 @@ export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
|
208
369
|
}
|
|
209
370
|
}
|
|
210
371
|
|
|
211
|
-
// 4 — launch + refresh
|
|
372
|
+
// 4 — launch + refresh + register login autostart (so a reboot brings the tray back).
|
|
212
373
|
let launched = false
|
|
374
|
+
let autostart: EnsureAutostartResult | undefined
|
|
213
375
|
if (opts.launch && present) {
|
|
214
376
|
dequarantineApp(run)
|
|
215
377
|
launchSwiftBar(run)
|
|
216
378
|
refreshSwiftBar(run)
|
|
217
379
|
launched = true
|
|
380
|
+
// Idempotent: the plist is byte-stable and the bootstrap is a no-op when loaded, so
|
|
381
|
+
// re-activation never plants a duplicate autostart.
|
|
382
|
+
autostart = ensureSwiftBarAutostart(env)
|
|
218
383
|
}
|
|
219
384
|
|
|
220
|
-
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
|
|
385
|
+
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched, autostart }
|
|
221
386
|
}
|
|
222
387
|
|
|
223
388
|
export interface UninstallTrayResult {
|
|
224
389
|
removed: string[]
|
|
225
390
|
/** SwiftBar was refreshed to drop the now-missing plugin. */
|
|
226
391
|
refreshed: boolean
|
|
392
|
+
/** Login-autostart teardown outcome (the GUI-face activation is being removed). */
|
|
393
|
+
autostart: RemoveAutostartResult
|
|
227
394
|
}
|
|
228
395
|
|
|
229
396
|
/**
|
|
@@ -257,7 +424,11 @@ export function uninstallTray(
|
|
|
257
424
|
refreshSwiftBar(run)
|
|
258
425
|
refreshed = true
|
|
259
426
|
}
|
|
260
|
-
|
|
427
|
+
// Tear down the login-autostart LaunchAgent too — removing the GUI face should not
|
|
428
|
+
// leave a reboot re-launching SwiftBar. Never touches the fleet (bootout of our own
|
|
429
|
+
// com.agfpd.* label only).
|
|
430
|
+
const autostart = removeSwiftBarAutostart(env)
|
|
431
|
+
return { removed, refreshed, autostart }
|
|
261
432
|
}
|
|
262
433
|
|
|
263
434
|
/**
|
|
@@ -294,6 +465,8 @@ export interface TrayStatus {
|
|
|
294
465
|
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
295
466
|
swiftbar: { installed: boolean; pluginDir?: string }
|
|
296
467
|
plugin: { installed: boolean; path?: string }
|
|
468
|
+
/** Login-autostart LaunchAgent: registered (our plist present) + its path. */
|
|
469
|
+
autostart: { registered: boolean; path?: string }
|
|
297
470
|
}
|
|
298
471
|
|
|
299
472
|
/** Read-only diagnostics — never repairs anything. */
|
|
@@ -305,9 +478,12 @@ export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeA
|
|
|
305
478
|
const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
|
|
306
479
|
const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
|
|
307
480
|
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
481
|
+
const autostartPath = swiftBarAutostartPlistPath(env)
|
|
482
|
+
const autostartOurs = existsSync(autostartPath) && isFoundationOwnedPlist(autostartPath)
|
|
308
483
|
return {
|
|
309
484
|
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
310
485
|
swiftbar: { installed, pluginDir: cfgDir },
|
|
311
486
|
plugin: { installed: Boolean(found), path: found },
|
|
487
|
+
autostart: { registered: autostartOurs, path: autostartOurs ? autostartPath : undefined },
|
|
312
488
|
}
|
|
313
489
|
}
|