@agfpd/iapeer 0.4.63 → 0.4.65
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/12-cli-reference.md +12 -0
- package/docs/15-fleet-api.md +2 -2
- package/docs/16-tray.md +88 -0
- package/docs/README.md +1 -0
- package/docs/ru/15-fleet-api.md +2 -2
- package/docs/ru/16-tray.md +83 -0
- package/docs/ru/README.md +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +113 -14
- package/src/supervisor/daemon.ts +64 -20
- package/src/tray/client.ts +233 -0
- package/src/tray/index.ts +146 -0
- package/src/tray/install.ts +266 -0
- package/src/tray/render.ts +243 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// tray/client — the fleet-API HTTP client. Ф1 of iapeer-tray is the FIRST EXTERNAL
|
|
2
|
+
// client of the daemon's fleet API, and it is written ONLY against the normative
|
|
3
|
+
// contract docs/15-fleet-api.md — NOT against daemon source. This module therefore
|
|
4
|
+
// imports NOTHING from src/daemon: it discovers the daemon via router.json, speaks
|
|
5
|
+
// plain HTTP+JSON (+SSE) over the two advertised listeners, and models the snapshot
|
|
6
|
+
// with its OWN local interfaces (client obligation 2: unknown fields ignored). If the
|
|
7
|
+
// doc and the daemon ever diverge, this client feels it — that is the whole point of
|
|
8
|
+
// dogfooding the contract through a real second client.
|
|
9
|
+
//
|
|
10
|
+
// Transport: prefer the 0600 unix socket (same-uid, same trust class as the CLI),
|
|
11
|
+
// fall back to the TCP loopback. Bun's fetch carries the `unix` option; the compiled
|
|
12
|
+
// `iapeer` binary bundles the Bun runtime, so this works when SwiftBar execs it.
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'fs'
|
|
15
|
+
import { join } from 'path'
|
|
16
|
+
import { pluginStateDir, type StorageOptions } from '../storage/index.ts'
|
|
17
|
+
|
|
18
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
19
|
+
// Discovery — router.json (docs/15 §Surface and transport)
|
|
20
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FleetAddress {
|
|
23
|
+
/** Unix-socket path (preferred). */
|
|
24
|
+
sock?: string
|
|
25
|
+
/** TCP origin, e.g. `http://127.0.0.1:8765` (derived from router.json `tcp`). */
|
|
26
|
+
tcp?: string
|
|
27
|
+
/** The daemon's advertised version, when present. */
|
|
28
|
+
version?: string
|
|
29
|
+
/** The fleet capability marker: `1` when the daemon serves /fleet/v1. Absent ⇒
|
|
30
|
+
* a pre-fleet daemon ⇒ the client must degrade (docs/15 §Discovery). */
|
|
31
|
+
fleet?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Read `~/.iapeer/state/iapeer/router.json`. Absent/unreadable ⇒ empty address
|
|
35
|
+
* (daemon down) — the caller renders the down state. Never throws. */
|
|
36
|
+
export function resolveFleetAddress(opts: StorageOptions = {}): FleetAddress {
|
|
37
|
+
const routerJson = join(pluginStateDir('iapeer', opts), 'router.json')
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(routerJson, 'utf8')) as {
|
|
40
|
+
sock?: unknown
|
|
41
|
+
tcp?: unknown
|
|
42
|
+
version?: unknown
|
|
43
|
+
fleet?: unknown
|
|
44
|
+
}
|
|
45
|
+
const addr: FleetAddress = {}
|
|
46
|
+
if (typeof parsed.sock === 'string' && parsed.sock) addr.sock = parsed.sock
|
|
47
|
+
// router.json advertises the MCP url (…/mcp); the fleet surface lives on the SAME
|
|
48
|
+
// origin under /fleet/v1 — keep only the origin.
|
|
49
|
+
if (typeof parsed.tcp === 'string' && parsed.tcp) {
|
|
50
|
+
try {
|
|
51
|
+
addr.tcp = new URL(parsed.tcp).origin
|
|
52
|
+
} catch {
|
|
53
|
+
/* malformed tcp url → no tcp fallback */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed.version === 'string') addr.version = parsed.version
|
|
57
|
+
if (typeof parsed.fleet === 'number') addr.fleet = parsed.fleet
|
|
58
|
+
return addr
|
|
59
|
+
} catch {
|
|
60
|
+
return {}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
// Requests — unix-first, TCP-fallback, optional bearer
|
|
66
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
/** The bearer, when the host configured H8 auth (docs/15 §Auth). Read from the
|
|
69
|
+
* env the client runs under; open-local hosts have none. */
|
|
70
|
+
function bearerHeader(env: NodeJS.ProcessEnv): Record<string, string> {
|
|
71
|
+
const t = env.IAPEER_BEARER_TOKEN?.trim()
|
|
72
|
+
return t ? { authorization: `Bearer ${t}` } : {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class FleetUnreachableError extends Error {
|
|
76
|
+
constructor(readonly detail: string) {
|
|
77
|
+
super(`fleet daemon unreachable: ${detail}`)
|
|
78
|
+
this.name = 'FleetUnreachableError'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface FetchAttempt {
|
|
83
|
+
url: string
|
|
84
|
+
init: RequestInit & { unix?: string }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Build the ordered transport attempts for a fleet path: unix socket first, TCP
|
|
88
|
+
* second. `unix` is Bun's fetch option (routes the HTTP request over the socket). */
|
|
89
|
+
function attempts(addr: FleetAddress, path: string, base: RequestInit, env: NodeJS.ProcessEnv): FetchAttempt[] {
|
|
90
|
+
const headers = { ...(base.headers as Record<string, string> | undefined), ...bearerHeader(env) }
|
|
91
|
+
const list: FetchAttempt[] = []
|
|
92
|
+
if (addr.sock) list.push({ url: `http://iapeer${path}`, init: { ...base, headers, unix: addr.sock } })
|
|
93
|
+
if (addr.tcp) list.push({ url: `${addr.tcp}${path}`, init: { ...base, headers } })
|
|
94
|
+
return list
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function fetchFleet(
|
|
98
|
+
addr: FleetAddress,
|
|
99
|
+
path: string,
|
|
100
|
+
init: RequestInit,
|
|
101
|
+
env: NodeJS.ProcessEnv,
|
|
102
|
+
): Promise<Response> {
|
|
103
|
+
const tries = attempts(addr, path, init, env)
|
|
104
|
+
if (tries.length === 0) throw new FleetUnreachableError('no advertised address (router.json missing sock/tcp)')
|
|
105
|
+
let lastErr = ''
|
|
106
|
+
for (const a of tries) {
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(a.url, a.init as RequestInit)
|
|
109
|
+
return res
|
|
110
|
+
} catch (e) {
|
|
111
|
+
lastErr = e instanceof Error ? e.message : String(e)
|
|
112
|
+
// try the next transport
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
throw new FleetUnreachableError(lastErr || 'all transports failed')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function fleetGetJson<T = unknown>(addr: FleetAddress, path: string, env: NodeJS.ProcessEnv): Promise<T> {
|
|
119
|
+
const res = await fetchFleet(addr, path, { method: 'GET' }, env)
|
|
120
|
+
const text = await res.text()
|
|
121
|
+
if (!res.ok) throw new Error(`GET ${path} → ${res.status}: ${text.slice(0, 300)}`)
|
|
122
|
+
return JSON.parse(text) as T
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function fleetPostJson<T = unknown>(
|
|
126
|
+
addr: FleetAddress,
|
|
127
|
+
path: string,
|
|
128
|
+
body: Record<string, unknown>,
|
|
129
|
+
env: NodeJS.ProcessEnv,
|
|
130
|
+
): Promise<{ status: number; body: T }> {
|
|
131
|
+
const res = await fetchFleet(
|
|
132
|
+
addr,
|
|
133
|
+
path,
|
|
134
|
+
{ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) },
|
|
135
|
+
env,
|
|
136
|
+
)
|
|
137
|
+
const text = await res.text()
|
|
138
|
+
let parsed: T
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(text) as T
|
|
141
|
+
} catch {
|
|
142
|
+
parsed = text as unknown as T
|
|
143
|
+
}
|
|
144
|
+
return { status: res.status, body: parsed }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// SSE — GET /fleet/v1/events (docs/15 §GET /fleet/v1/events)
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export interface SseEvent {
|
|
152
|
+
/** The event name (`event:` line) — the `ev` vocabulary. Unknown kinds MUST be
|
|
153
|
+
* tolerated by the caller (client obligation 1). */
|
|
154
|
+
event: string
|
|
155
|
+
/** Epoch-ms of the event (`id:` line) — for at-least-once dedup. */
|
|
156
|
+
id?: number
|
|
157
|
+
/** The parsed `data:` JSON payload. */
|
|
158
|
+
data: Record<string, unknown>
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Open the SSE stream and invoke `onEvent` per frame until the signal aborts or the
|
|
163
|
+
* connection drops (then this resolves — the caller decides whether to reconnect).
|
|
164
|
+
* Parses the `event:` / `id:` / `data:` triplet; comments (`: connected`, `: hb`) are
|
|
165
|
+
* skipped. Deliberately minimal — no reconnect here (the render loop owns that).
|
|
166
|
+
*/
|
|
167
|
+
export async function streamFleetEvents(
|
|
168
|
+
addr: FleetAddress,
|
|
169
|
+
onEvent: (e: SseEvent) => void,
|
|
170
|
+
opts: { replay?: number; signal?: AbortSignal; env: NodeJS.ProcessEnv },
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const replay = Math.max(0, Math.min(500, opts.replay ?? 0))
|
|
173
|
+
const res = await fetchFleet(
|
|
174
|
+
addr,
|
|
175
|
+
`/fleet/v1/events?replay=${replay}`,
|
|
176
|
+
{ method: 'GET', headers: { accept: 'text/event-stream' }, signal: opts.signal },
|
|
177
|
+
opts.env,
|
|
178
|
+
)
|
|
179
|
+
if (!res.ok || !res.body) throw new Error(`events stream → ${res.status}`)
|
|
180
|
+
const reader = res.body.getReader()
|
|
181
|
+
const decoder = new TextDecoder()
|
|
182
|
+
let buf = ''
|
|
183
|
+
try {
|
|
184
|
+
for (;;) {
|
|
185
|
+
const { value, done } = await reader.read()
|
|
186
|
+
if (done) break
|
|
187
|
+
buf += decoder.decode(value, { stream: true })
|
|
188
|
+
// Frames are separated by a blank line.
|
|
189
|
+
let sep: number
|
|
190
|
+
while ((sep = buf.indexOf('\n\n')) !== -1) {
|
|
191
|
+
const frame = buf.slice(0, sep)
|
|
192
|
+
buf = buf.slice(sep + 2)
|
|
193
|
+
const parsed = parseSseFrame(frame)
|
|
194
|
+
if (parsed) onEvent(parsed)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} finally {
|
|
198
|
+
try {
|
|
199
|
+
reader.releaseLock()
|
|
200
|
+
} catch {
|
|
201
|
+
/* already released */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Parse one SSE frame (the text between blank-line separators). Returns null for
|
|
207
|
+
* comment-only frames (`: connected`, `: hb`) or frames without a data payload. */
|
|
208
|
+
export function parseSseFrame(frame: string): SseEvent | null {
|
|
209
|
+
let event = 'message'
|
|
210
|
+
let id: number | undefined
|
|
211
|
+
const dataLines: string[] = []
|
|
212
|
+
for (const raw of frame.split('\n')) {
|
|
213
|
+
const line = raw.replace(/\r$/, '')
|
|
214
|
+
if (!line || line.startsWith(':')) continue // comment or blank
|
|
215
|
+
const colon = line.indexOf(':')
|
|
216
|
+
const field = colon === -1 ? line : line.slice(0, colon)
|
|
217
|
+
const val = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
|
|
218
|
+
if (field === 'event') event = val
|
|
219
|
+
else if (field === 'id') {
|
|
220
|
+
const n = parseInt(val, 10)
|
|
221
|
+
if (Number.isFinite(n)) id = n
|
|
222
|
+
} else if (field === 'data') dataLines.push(val)
|
|
223
|
+
}
|
|
224
|
+
if (dataLines.length === 0) return null
|
|
225
|
+
let data: Record<string, unknown> = {}
|
|
226
|
+
try {
|
|
227
|
+
const parsed = JSON.parse(dataLines.join('\n'))
|
|
228
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) data = parsed as Record<string, unknown>
|
|
229
|
+
} catch {
|
|
230
|
+
return null
|
|
231
|
+
}
|
|
232
|
+
return { event, ...(id !== undefined ? { id } : {}), data }
|
|
233
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tray/index — the fleet dashboard's runtime entry points, called by the `tray` CLI
|
|
2
|
+
// verb. Ф1 of iapeer-tray: the FIRST external client of the daemon fleet API, written
|
|
3
|
+
// strictly against docs/15-fleet-api.md.
|
|
4
|
+
//
|
|
5
|
+
// • render (one-shot) — fetch /snapshot once, print one SwiftBar block. Used by the
|
|
6
|
+
// poll-fallback plugin and by tests.
|
|
7
|
+
// • render --stream — the SwiftBar streamable loop: print a block, subscribe to
|
|
8
|
+
// /events, and re-fetch the snapshot on each change (the canonical docs/15 loop
|
|
9
|
+
// "snapshot is state, events are hints"). Coalesces event bursts, heartbeats a
|
|
10
|
+
// refresh every 15 s (ages stay fresh + a missed event self-heals), and reconnects
|
|
11
|
+
// across daemon restarts / down windows. Degrades to the daemon-down block.
|
|
12
|
+
// • cmd — POST a fleet command (wake/stop/…): dogfoods the command endpoints.
|
|
13
|
+
|
|
14
|
+
import { resolveFleetAddress, fleetGetJson, fleetPostJson, streamFleetEvents } from './client.ts'
|
|
15
|
+
import { renderSwiftBar, renderDaemonDown, type TraySnapshot } from './render.ts'
|
|
16
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
17
|
+
|
|
18
|
+
export * from './install.ts'
|
|
19
|
+
export { resolveFleetAddress } from './client.ts'
|
|
20
|
+
|
|
21
|
+
const SNAPSHOT_PATH = '/fleet/v1/snapshot'
|
|
22
|
+
const DEFAULT_HEARTBEAT_MS = 15_000
|
|
23
|
+
const COALESCE_MS = 200
|
|
24
|
+
const RECONNECT_BACKOFF_MS = 1_000
|
|
25
|
+
const DOWN_RETRY_MS = 3_000
|
|
26
|
+
|
|
27
|
+
/** Heartbeat cadence: re-render even without an event so ages stay fresh and a missed
|
|
28
|
+
* event self-heals. Tunable via IAPEER_TRAY_HEARTBEAT_MS (clamped 1s…10m). */
|
|
29
|
+
function heartbeatMs(env: NodeJS.ProcessEnv): number {
|
|
30
|
+
const raw = parseInt(env.IAPEER_TRAY_HEARTBEAT_MS ?? '', 10)
|
|
31
|
+
if (!Number.isFinite(raw)) return DEFAULT_HEARTBEAT_MS
|
|
32
|
+
return Math.max(1_000, Math.min(600_000, raw))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const TRAY_COMMANDS = ['wake', 'stop', 'start', 'new', 'refresh', 'interrupt', 'compact'] as const
|
|
36
|
+
export type TrayCommand = (typeof TRAY_COMMANDS)[number]
|
|
37
|
+
|
|
38
|
+
/** Fetch the snapshot and render one SwiftBar block, or the degraded down-block when
|
|
39
|
+
* no advertised address answers / the daemon predates the fleet API. Never throws. */
|
|
40
|
+
export async function renderTrayOnce(env: NodeJS.ProcessEnv): Promise<string> {
|
|
41
|
+
const binPath = iapeerBinPath(env)
|
|
42
|
+
const addr = resolveFleetAddress({ env })
|
|
43
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
44
|
+
const reason = addr.sock || addr.tcp ? 'daemon predates the fleet API (no fleet:1 in router.json)' : 'no daemon address (router.json missing)'
|
|
45
|
+
return renderDaemonDown(reason, { binPath })
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const snap = await fleetGetJson<TraySnapshot>(addr, SNAPSHOT_PATH, env)
|
|
49
|
+
return renderSwiftBar(snap, { binPath })
|
|
50
|
+
} catch (e) {
|
|
51
|
+
return renderDaemonDown(e instanceof Error ? e.message : String(e), { binPath })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
if (signal?.aborted) return resolve()
|
|
58
|
+
const t = setTimeout(resolve, ms)
|
|
59
|
+
;(t as { unref?: () => void }).unref?.()
|
|
60
|
+
signal?.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The SwiftBar streamable loop (docs/16 §streaming). Emits a full menu block, then a
|
|
66
|
+
* `~~~` separator before every subsequent block. Drives updates off the SSE stream
|
|
67
|
+
* with burst-coalescing + a heartbeat; reconnects forever. `out` defaults to stdout;
|
|
68
|
+
* `signal` (tests) stops the loop.
|
|
69
|
+
*/
|
|
70
|
+
export async function streamTray(
|
|
71
|
+
env: NodeJS.ProcessEnv,
|
|
72
|
+
out: (s: string) => void = s => process.stdout.write(s),
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
let first = true
|
|
76
|
+
// Serialize all emits through a promise chain so a `~~~`+block is never interleaved.
|
|
77
|
+
let chain: Promise<void> = Promise.resolve()
|
|
78
|
+
const emit = (): Promise<void> => {
|
|
79
|
+
chain = chain.then(async () => {
|
|
80
|
+
const block = await renderTrayOnce(env)
|
|
81
|
+
out(first ? block : `~~~\n${block}`)
|
|
82
|
+
first = false
|
|
83
|
+
})
|
|
84
|
+
return chain
|
|
85
|
+
}
|
|
86
|
+
// Coalesce event bursts into a single re-render.
|
|
87
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | undefined
|
|
88
|
+
const requestEmit = (): void => {
|
|
89
|
+
if (coalesceTimer) return
|
|
90
|
+
coalesceTimer = setTimeout(() => {
|
|
91
|
+
coalesceTimer = undefined
|
|
92
|
+
void emit()
|
|
93
|
+
}, COALESCE_MS)
|
|
94
|
+
;(coalesceTimer as { unref?: () => void }).unref?.()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await emit() // initial block
|
|
98
|
+
|
|
99
|
+
while (!signal?.aborted) {
|
|
100
|
+
const addr = resolveFleetAddress({ env })
|
|
101
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
102
|
+
await sleep(DOWN_RETRY_MS, signal)
|
|
103
|
+
await emit() // keep the down-block current until the daemon returns
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
const heartbeat = setInterval(requestEmit, heartbeatMs(env))
|
|
107
|
+
;(heartbeat as { unref?: () => void }).unref?.()
|
|
108
|
+
try {
|
|
109
|
+
// Blocks until the stream ends (daemon restart / network drop). Each event just
|
|
110
|
+
// marks the menu dirty — the snapshot re-fetch is the source of truth.
|
|
111
|
+
await streamFleetEvents(addr, () => requestEmit(), { replay: 0, signal, env })
|
|
112
|
+
} catch {
|
|
113
|
+
/* connection failed → fall through to a refresh + backoff + reconnect */
|
|
114
|
+
} finally {
|
|
115
|
+
clearInterval(heartbeat)
|
|
116
|
+
}
|
|
117
|
+
if (signal?.aborted) break
|
|
118
|
+
await emit() // reflect whatever changed across the drop
|
|
119
|
+
await sleep(RECONNECT_BACKOFF_MS, signal)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface TrayCmdResult {
|
|
124
|
+
ok: boolean
|
|
125
|
+
status: number
|
|
126
|
+
body: unknown
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** POST a fleet command for a peer (dogfoods POST /fleet/v1/peers/<peer>/<cmd>). */
|
|
130
|
+
export async function trayCmd(
|
|
131
|
+
env: NodeJS.ProcessEnv,
|
|
132
|
+
command: string,
|
|
133
|
+
personality: string,
|
|
134
|
+
runtime?: string,
|
|
135
|
+
): Promise<TrayCmdResult> {
|
|
136
|
+
if (!(TRAY_COMMANDS as readonly string[]).includes(command)) {
|
|
137
|
+
throw new Error(`unknown tray command "${command}" — one of: ${TRAY_COMMANDS.join(', ')}`)
|
|
138
|
+
}
|
|
139
|
+
const addr = resolveFleetAddress({ env })
|
|
140
|
+
if (addr.fleet !== 1 || (!addr.sock && !addr.tcp)) {
|
|
141
|
+
return { ok: false, status: 0, body: { error: 'fleet daemon unreachable (no fleet:1 in router.json)' } }
|
|
142
|
+
}
|
|
143
|
+
const path = `/fleet/v1/peers/${encodeURIComponent(personality)}/${command}`
|
|
144
|
+
const { status, body } = await fleetPostJson(addr, path, runtime ? { runtime } : {}, env)
|
|
145
|
+
return { ok: status < 400, status, body }
|
|
146
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// tray/install — idempotent lifecycle of the SwiftBar plugin that hosts the fleet
|
|
2
|
+
// dashboard. The plugin FILE is a thin shim: it execs the installed `iapeer` binary
|
|
3
|
+
// (`iapeer tray render --stream`), so ALL rendering/streaming logic lives in the
|
|
4
|
+
// binary (TypeScript, tested) rather than in brittle shell — the .sh is stable and
|
|
5
|
+
// carries only SwiftBar metadata.
|
|
6
|
+
//
|
|
7
|
+
// SwiftBar facts (verified against SwiftBar/PreferencesStore.swift):
|
|
8
|
+
// • PluginDirectory is a PLAIN String default (read via expandingTildeInPath), NOT
|
|
9
|
+
// a security-scoped bookmark — `defaults write com.ameba.SwiftBar PluginDirectory
|
|
10
|
+
// -string <dir>` set BEFORE first launch is honored and skips the folder picker.
|
|
11
|
+
// • SwiftBar is not sandboxed (it runs arbitrary scripts), so a hidden ~/.iapeer
|
|
12
|
+
// path is readable without a TCC prompt.
|
|
13
|
+
//
|
|
14
|
+
// Packaging (owner decision 05.07.2026): the tray ships WITH the foundation (same
|
|
15
|
+
// class as the `iapeer list` TUI — another face of the daemon). `iapeer install`
|
|
16
|
+
// drops the plugin file (inert without SwiftBar); `iapeer tray install [--app]` is
|
|
17
|
+
// the activation verb that also installs SwiftBar.app when absent (owner-sanctioned)
|
|
18
|
+
// and launches it. Tearing down the GUI layer NEVER touches the fleet.
|
|
19
|
+
//
|
|
20
|
+
// Paths route through cfg/env (the dedicated plugin dir is under resolveGlobalRoot,
|
|
21
|
+
// so IAPEER_ROOT isolates it in tests); every external side-effect (defaults / brew /
|
|
22
|
+
// open) goes through an injectable Runner so tests stay hermetic.
|
|
23
|
+
|
|
24
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
25
|
+
import { spawnSync } from 'child_process'
|
|
26
|
+
import { homedir } from 'os'
|
|
27
|
+
import { join } from 'path'
|
|
28
|
+
import { resolveGlobalRoot } from '../storage/index.ts'
|
|
29
|
+
import { iapeerBinPath } from '../install/index.ts'
|
|
30
|
+
import { resolveFleetAddress } from './client.ts'
|
|
31
|
+
|
|
32
|
+
/** Streamable plugin: the `.10s.` token is the RESPAWN cadence SwiftBar uses if the
|
|
33
|
+
* streaming process exits (daemon-down backoff); while the stream is alive it is
|
|
34
|
+
* ignored. The basename is stable — install rewrites the same file (idempotent). */
|
|
35
|
+
export const PLUGIN_BASENAME = 'iapeer.10s.sh'
|
|
36
|
+
const SWIFTBAR_DOMAIN = 'com.ameba.SwiftBar'
|
|
37
|
+
const SWIFTBAR_APP_PATHS = ['/Applications/SwiftBar.app', join(homedir(), 'Applications', 'SwiftBar.app')]
|
|
38
|
+
|
|
39
|
+
export type RunResult = { status: number | null; stdout: string; stderr: string }
|
|
40
|
+
export type Runner = (cmd: string, args: string[]) => RunResult
|
|
41
|
+
|
|
42
|
+
const defaultRun: Runner = (cmd, args) => {
|
|
43
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' })
|
|
44
|
+
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The dedicated iapeer-owned plugin dir under ~/.iapeer (IAPEER_ROOT-isolated). */
|
|
48
|
+
export function dedicatedPluginDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
49
|
+
return join(resolveGlobalRoot(env), 'tray', 'plugins')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Is SwiftBar.app installed? (either /Applications or ~/Applications). */
|
|
53
|
+
export function swiftBarInstalled(): boolean {
|
|
54
|
+
return SWIFTBAR_APP_PATHS.some(p => existsSync(p))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The plugin-dir SwiftBar is currently configured with (plain-string default,
|
|
58
|
+
* ~-expanded), or undefined when unset/unreadable. */
|
|
59
|
+
export function readSwiftBarPluginDir(run: Runner = defaultRun): string | undefined {
|
|
60
|
+
const r = run('defaults', ['read', SWIFTBAR_DOMAIN, 'PluginDirectory'])
|
|
61
|
+
if (r.status !== 0) return undefined
|
|
62
|
+
const raw = r.stdout.trim()
|
|
63
|
+
if (!raw) return undefined
|
|
64
|
+
return raw.startsWith('~') ? join(homedir(), raw.slice(1)) : raw
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function setSwiftBarPluginDir(dir: string, run: Runner): RunResult {
|
|
68
|
+
return run('defaults', ['write', SWIFTBAR_DOMAIN, 'PluginDirectory', '-string', dir])
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function installSwiftBarApp(run: Runner): RunResult {
|
|
72
|
+
return run('brew', ['install', '--cask', 'swiftbar'])
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function launchSwiftBar(run: Runner): void {
|
|
76
|
+
run('open', ['-g', '-a', 'SwiftBar'])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function refreshSwiftBar(run: Runner): void {
|
|
80
|
+
run('open', ['-g', 'swiftbar://refreshallplugins'])
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The plugin shim: SwiftBar metadata + exec of the streaming renderer. binPath is
|
|
84
|
+
* embedded absolute (SwiftBar's PATH is minimal, like launchd). */
|
|
85
|
+
export function buildPluginShim(binPath: string): string {
|
|
86
|
+
return [
|
|
87
|
+
'#!/bin/bash',
|
|
88
|
+
'# <xbar.title>iapeer fleet</xbar.title>',
|
|
89
|
+
'# <xbar.version>v1</xbar.version>',
|
|
90
|
+
'# <xbar.author>iapeer</xbar.author>',
|
|
91
|
+
'# <xbar.desc>iapeer multi-agent fleet — live peer dashboard over the daemon fleet API.</xbar.desc>',
|
|
92
|
+
'# <swiftbar.type>streamable</swiftbar.type>',
|
|
93
|
+
'# <swiftbar.hideRunInTerminal>true</swiftbar.hideRunInTerminal>',
|
|
94
|
+
'# <swiftbar.hideLastUpdated>true</swiftbar.hideLastUpdated>',
|
|
95
|
+
'# <swiftbar.hideDisablePlugin>true</swiftbar.hideDisablePlugin>',
|
|
96
|
+
`exec "${binPath}" tray render --stream`,
|
|
97
|
+
'',
|
|
98
|
+
].join('\n')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fail-closed sandbox guard (in spirit with install/scaffoldHostDocs): never write
|
|
102
|
+
* into the REAL ~/.iapeer under a sandboxed test that forgot to set IAPEER_ROOT. */
|
|
103
|
+
function assertTraySandboxIsolated(env: NodeJS.ProcessEnv): void {
|
|
104
|
+
if (env.IAPEER_TEST_SANDBOX === '1' && resolveGlobalRoot(env) === join(homedir(), '.iapeer')) {
|
|
105
|
+
throw new Error('refusing to install the tray plugin into the REAL ~/.iapeer under IAPEER_TEST_SANDBOX=1 — set IAPEER_ROOT')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface InstallTrayOptions {
|
|
110
|
+
env?: NodeJS.ProcessEnv
|
|
111
|
+
/** Install SwiftBar.app via brew when absent (owner-sanctioned). Default false. */
|
|
112
|
+
installApp?: boolean
|
|
113
|
+
/** Launch + refresh SwiftBar after writing the plugin. Default false. */
|
|
114
|
+
launch?: boolean
|
|
115
|
+
run?: Runner
|
|
116
|
+
/** Injectable app-presence probe (tests). Default: swiftBarInstalled(). */
|
|
117
|
+
probeApp?: () => boolean
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface InstallTrayResult {
|
|
121
|
+
pluginFile: string
|
|
122
|
+
pluginDir: string
|
|
123
|
+
/** Whether the plugin file bytes changed (false = already current). */
|
|
124
|
+
wrote: boolean
|
|
125
|
+
/** How the plugin dir was chosen. */
|
|
126
|
+
dir: 'existing-swiftbar' | 'dedicated-configured' | 'dedicated-only'
|
|
127
|
+
app: 'present' | 'installed' | 'install-failed' | 'absent'
|
|
128
|
+
appReason?: string
|
|
129
|
+
launched: boolean
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Idempotently install the tray plugin. Steps (each safe to repeat):
|
|
134
|
+
* 1. install SwiftBar.app if absent && installApp (best-effort — a brew failure is
|
|
135
|
+
* reported, not thrown; the plugin file still lands so it activates later).
|
|
136
|
+
* 2. resolve the target dir: an already-configured SwiftBar PluginDirectory is
|
|
137
|
+
* RESPECTED (install into the user's dir); otherwise the dedicated ~/.iapeer dir,
|
|
138
|
+
* and — only when SwiftBar is present — point SwiftBar at it via defaults.
|
|
139
|
+
* 3. write the plugin shim (skip if byte-identical; always ensure +x).
|
|
140
|
+
* 4. launch + refresh SwiftBar when launch && SwiftBar present.
|
|
141
|
+
*/
|
|
142
|
+
export function installTray(opts: InstallTrayOptions = {}): InstallTrayResult {
|
|
143
|
+
const env = opts.env ?? process.env
|
|
144
|
+
const run = opts.run ?? defaultRun
|
|
145
|
+
const probeApp = opts.probeApp ?? swiftBarInstalled
|
|
146
|
+
assertTraySandboxIsolated(env)
|
|
147
|
+
const binPath = iapeerBinPath(env)
|
|
148
|
+
|
|
149
|
+
// 1 — app
|
|
150
|
+
let app: InstallTrayResult['app'] = probeApp() ? 'present' : 'absent'
|
|
151
|
+
let appReason: string | undefined
|
|
152
|
+
if (app === 'absent' && opts.installApp) {
|
|
153
|
+
const r = installSwiftBarApp(run)
|
|
154
|
+
if (r.status === 0 || probeApp()) app = 'installed'
|
|
155
|
+
else {
|
|
156
|
+
app = 'install-failed'
|
|
157
|
+
appReason = (r.stderr || r.stdout || `brew exit ${r.status}`).trim().slice(0, 300)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const present = app === 'present' || app === 'installed'
|
|
161
|
+
|
|
162
|
+
// 2 — target dir
|
|
163
|
+
const existing = present ? readSwiftBarPluginDir(run) : undefined
|
|
164
|
+
let pluginDir: string
|
|
165
|
+
let dir: InstallTrayResult['dir']
|
|
166
|
+
if (existing && existsSync(existing)) {
|
|
167
|
+
pluginDir = existing
|
|
168
|
+
dir = 'existing-swiftbar'
|
|
169
|
+
} else {
|
|
170
|
+
pluginDir = dedicatedPluginDir(env)
|
|
171
|
+
if (present) {
|
|
172
|
+
setSwiftBarPluginDir(pluginDir, run)
|
|
173
|
+
dir = 'dedicated-configured'
|
|
174
|
+
} else {
|
|
175
|
+
dir = 'dedicated-only'
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(pluginDir, { recursive: true })
|
|
179
|
+
|
|
180
|
+
// 3 — plugin file (idempotent write; always +x)
|
|
181
|
+
const pluginFile = join(pluginDir, PLUGIN_BASENAME)
|
|
182
|
+
const content = buildPluginShim(binPath)
|
|
183
|
+
let wrote = false
|
|
184
|
+
if (!existsSync(pluginFile) || readFileSync(pluginFile, 'utf8') !== content) {
|
|
185
|
+
writeFileSync(pluginFile, content, { mode: 0o755 })
|
|
186
|
+
wrote = true
|
|
187
|
+
} else {
|
|
188
|
+
try {
|
|
189
|
+
chmodSync(pluginFile, 0o755)
|
|
190
|
+
} catch {
|
|
191
|
+
/* best-effort */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 4 — launch + refresh
|
|
196
|
+
let launched = false
|
|
197
|
+
if (opts.launch && present) {
|
|
198
|
+
launchSwiftBar(run)
|
|
199
|
+
refreshSwiftBar(run)
|
|
200
|
+
launched = true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return { pluginFile, pluginDir, wrote, dir, app, appReason, launched }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface UninstallTrayResult {
|
|
207
|
+
removed: string[]
|
|
208
|
+
/** SwiftBar was refreshed to drop the now-missing plugin. */
|
|
209
|
+
refreshed: boolean
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Remove the tray plugin file from EVERY dir it could live in (the dedicated dir and
|
|
214
|
+
* a configured SwiftBar dir) and refresh SwiftBar. DELIBERATELY leaves SwiftBar.app
|
|
215
|
+
* installed (the user may run other plugins) and NEVER touches the fleet — the daemon,
|
|
216
|
+
* TUI and delivery keep running (acceptance criterion: GUI teardown ≠ fleet teardown).
|
|
217
|
+
*/
|
|
218
|
+
export function uninstallTray(
|
|
219
|
+
opts: { env?: NodeJS.ProcessEnv; launch?: boolean; run?: Runner; probeApp?: () => boolean } = {},
|
|
220
|
+
): UninstallTrayResult {
|
|
221
|
+
const env = opts.env ?? process.env
|
|
222
|
+
const run = opts.run ?? defaultRun
|
|
223
|
+
assertTraySandboxIsolated(env)
|
|
224
|
+
const present = (opts.probeApp ?? swiftBarInstalled)()
|
|
225
|
+
const dirs = new Set<string>([dedicatedPluginDir(env)])
|
|
226
|
+
if (present) {
|
|
227
|
+
const existing = readSwiftBarPluginDir(run)
|
|
228
|
+
if (existing) dirs.add(existing)
|
|
229
|
+
}
|
|
230
|
+
const removed: string[] = []
|
|
231
|
+
for (const d of dirs) {
|
|
232
|
+
const f = join(d, PLUGIN_BASENAME)
|
|
233
|
+
if (existsSync(f)) {
|
|
234
|
+
rmSync(f, { force: true })
|
|
235
|
+
removed.push(f)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
let refreshed = false
|
|
239
|
+
if (opts.launch && present) {
|
|
240
|
+
refreshSwiftBar(run)
|
|
241
|
+
refreshed = true
|
|
242
|
+
}
|
|
243
|
+
return { removed, refreshed }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export interface TrayStatus {
|
|
247
|
+
daemon: { fleet: boolean; version?: string; sock?: string; tcp?: string }
|
|
248
|
+
swiftbar: { installed: boolean; pluginDir?: string }
|
|
249
|
+
plugin: { installed: boolean; path?: string }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Read-only diagnostics — never repairs anything. */
|
|
253
|
+
export function trayStatus(opts: { env?: NodeJS.ProcessEnv; run?: Runner; probeApp?: () => boolean } = {}): TrayStatus {
|
|
254
|
+
const env = opts.env ?? process.env
|
|
255
|
+
const run = opts.run ?? defaultRun
|
|
256
|
+
const addr = resolveFleetAddress({ env })
|
|
257
|
+
const installed = (opts.probeApp ?? swiftBarInstalled)()
|
|
258
|
+
const cfgDir = installed ? readSwiftBarPluginDir(run) : undefined
|
|
259
|
+
const searchDirs = [cfgDir, dedicatedPluginDir(env)].filter(Boolean) as string[]
|
|
260
|
+
const found = searchDirs.map(d => join(d, PLUGIN_BASENAME)).find(f => existsSync(f))
|
|
261
|
+
return {
|
|
262
|
+
daemon: { fleet: addr.fleet === 1, version: addr.version, sock: addr.sock, tcp: addr.tcp },
|
|
263
|
+
swiftbar: { installed, pluginDir: cfgDir },
|
|
264
|
+
plugin: { installed: Boolean(found), path: found },
|
|
265
|
+
}
|
|
266
|
+
}
|