@agfpd/iapeer 0.4.81 → 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/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/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
|
}
|