diff --git a/packaging/ios-companion/README.md b/packaging/ios-companion/README.md index a6423eb..0c77a3e 100644 --- a/packaging/ios-companion/README.md +++ b/packaging/ios-companion/README.md @@ -38,9 +38,15 @@ iOS 17+ target). It covers: - **Deep-links** — `lisapocket://` opens the app from a Widget tap, an ntfy push, or an APNs push tap (the push carries the link to the relevant session). -**Not yet** (follow-ups): live **Live-Activity** updates via APNs (so a pinned agent -stays fresh while backgrounded). Plain APNs alerts are wired end-to-end (registration + -sender) but **delivery needs an Apple push key**; ntfy works today with no Apple infra. +- **Live-Activity remote refresh** — a pinned activity requests a push token (forwarded + to the Mac), and the push-bridge refreshes it over APNs (`liveactivity`) as the agent + updates, ending it on done/error — so it stays fresh while backgrounded. + +**Needs an Apple push key** (the only remaining external dependency): APNs alert delivery +*and* the Live-Activity refresh are wired end-to-end (iOS registration + token capture, a +token-auth APNs sender) but **inert until `LISA_APNS_*` is set on the Mac**; ntfy works +today with no Apple infra. Live APNs behavior is therefore unit-/compile-verified here, +not exercised against Apple. > Like the Live Activity, the home-screen Widget is **compile-verified on the > Simulator**. Its data only flows on a **signed** build: App Group capabilities aren't diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index e96e95f..d04bd20 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -133,6 +133,10 @@ final class LisaClient { } // ── push ── + /// Register (token non-empty) a Live Activity push token for a session. + func registerLiveActivity(sessionId: String, token: String) async throws { + try await fire("/api/push/live-activity", json: ["sessionId": sessionId, "token": token]) + } func pushRegister(kind: String, target: String, prefs: PushPrefs) async throws { let p: [String: Any] = ["done": prefs.done, "error": prefs.error, "permission": prefs.permission, "idle": prefs.idle, "advisor": prefs.advisor] try await fire("/api/push/register", json: ["kind": kind, "target": target, "prefs": p]) diff --git a/packaging/ios-companion/Sources/LiveActivityController.swift b/packaging/ios-companion/Sources/LiveActivityController.swift index 8accb16..0a80768 100644 --- a/packaging/ios-companion/Sources/LiveActivityController.swift +++ b/packaging/ios-companion/Sources/LiveActivityController.swift @@ -1,12 +1,13 @@ import ActivityKit import Foundation -/// Starts the Live Activity for a pinned agent. (Updating it as state changes is a -/// follow-up — ideally driven by APNs Live Activity remote updates so it stays -/// fresh while the app is backgrounded.) +/// Starts the Live Activity for a pinned agent, requesting a push token so the Mac +/// can refresh it remotely (APNs `liveactivity`) while the app is backgrounded — +/// the backend half is in src/web/push.ts (sendLiveActivityUpdate). Live remote +/// updates still need an Apple push key; without one the activity is local-only. enum LiveActivityController { @discardableResult - static func start(for session: AgentSession) -> String? { + static func start(for session: AgentSession, client: LisaClient? = nil) -> String? { guard ActivityAuthorizationInfo().areActivitiesEnabled else { return nil } let attributes = AgentActivityAttributes( agent: session.agent, @@ -21,8 +22,18 @@ enum LiveActivityController { do { let activity = try Activity.request( attributes: attributes, - content: .init(state: state, staleDate: nil) + content: .init(state: state, staleDate: nil), + pushType: .token ) + // Forward the activity's push token so the Mac can update it remotely. + if let client { + Task { + for await tokenData in activity.pushTokenUpdates { + let hex = tokenData.map { String(format: "%02x", $0) }.joined() + try? await client.registerLiveActivity(sessionId: session.sessionId, token: hex) + } + } + } return activity.id } catch { return nil diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index c2f53b9..e7feca5 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -236,7 +236,7 @@ struct SessionDetailView: View { controlSection Section("Glance") { Button { - if LiveActivityController.start(for: session) != nil { + if LiveActivityController.start(for: session, client: app.client) != nil { status = "Pinned to the Live Activity." } else { status = "Live Activities are unavailable here (enable in iOS Settings, or run on a device)." diff --git a/src/web/push.test.ts b/src/web/push.test.ts index dc161b5..2157a38 100644 --- a/src/web/push.test.ts +++ b/src/web/push.test.ts @@ -20,6 +20,12 @@ const { buildApnsJwt, buildApnsPayload, sendApns, + liveActivityState, + buildLiveActivityPayload, + sendLiveActivityUpdate, + registerLiveActivity, + unregisterLiveActivity, + listLiveActivities, PushBridge, registerPush, unregisterPush, @@ -230,3 +236,70 @@ describe("APNs", () => { assert.equal(bad, false); }); }); + +describe("Live Activity remote updates", () => { + const kp = crypto.generateKeyPairSync("ec", { namedCurve: "P-256" }); + const pem = kp.privateKey.export({ type: "pkcs8", format: "pem" }) as string; + const cfg = { keyId: "K1", teamId: "T1", key: pem, topic: "ai.meetlisa.pocket", host: "api.sandbox.push.apple.com" }; + + test("liveActivityState mirrors the app's content-state + detail()", () => { + assert.deepEqual( + liveActivityState(withPending("Bash")), + { state: "working", detail: "⚠ Bash", turns: 1 }, + ); + assert.deepEqual( + liveActivityState(sess({ state: "error", stateReason: "boom" })), + { state: "error", detail: "boom", turns: 0 }, + ); + }); + + test("buildLiveActivityPayload: aps event + content-state; end adds dismissal-date", () => { + const up = buildLiveActivityPayload({ state: "working", detail: "x", turns: 3 }, "update", 1000); + const aps = up.aps as Record; + assert.equal(aps.event, "update"); + assert.equal(aps.timestamp, 1000); + assert.deepEqual(aps["content-state"], { state: "working", detail: "x", turns: 3 }); + assert.equal(aps["dismissal-date"], undefined); + const end = buildLiveActivityPayload({ state: "done", detail: "x", turns: 3 }, "end", 1000); + assert.equal((end.aps as Record)["dismissal-date"], 1000); + }); + + test("sendLiveActivityUpdate: liveactivity push-type + topic suffix", async () => { + let captured: { path: string; headers: Record; body: string } | null = null; + const ok = await sendLiveActivityUpdate( + cfg, "latoken", { state: "working", detail: "x", turns: 1 }, "update", + async (o) => { captured = o; return { status: 200 }; }, 1000, + ); + assert.equal(ok, true); + assert.equal(captured!.path, "/3/device/latoken"); + assert.equal(captured!.headers["apns-topic"], "ai.meetlisa.pocket.push-type.liveactivity"); + assert.equal(captured!.headers["apns-push-type"], "liveactivity"); + }); + + test("store: register replaces per session; unregister removes", () => { + registerLiveActivity("sess-A", "tok1", 1); + registerLiveActivity("sess-A", "tok2", 2); // same session → replace + const a = listLiveActivities().filter((r) => r.sessionId === "sess-A"); + assert.equal(a.length, 1); + assert.equal(a[0]!.token, "tok2"); + assert.equal(unregisterLiveActivity("sess-A"), true); + assert.equal(listLiveActivities().some((r) => r.sessionId === "sess-A"), false); + }); + + test("PushBridge pushes an LA update for a registered session; ends + clears on done", () => { + const events: Array<{ token: string; event: string; state: string }> = []; + const regs = [{ sessionId: "s1", token: "tokX", createdAt: 0 }]; + const bridge = new PushBridge({ + subs: () => [], + liveActivities: () => regs.filter((r) => regs.includes(r)), + now: () => 100000, + liveDeliver: (token, cs, event) => void events.push({ token, event, state: cs.state }), + }); + bridge.onAgentUpdate(sess({ state: "working" })); // → update + bridge.onAgentUpdate(sess({ state: "done" })); // → end (terminal, not throttled) + assert.deepEqual(events, [ + { token: "tokX", event: "update", state: "working" }, + { token: "tokX", event: "end", state: "done" }, + ]); + }); +}); diff --git a/src/web/push.ts b/src/web/push.ts index 9359348..4c83dc7 100644 --- a/src/web/push.ts +++ b/src/web/push.ts @@ -115,6 +115,43 @@ export function setPushPrefs(id: string, prefs: Partial): PushSubscri return s; } +// ── Live Activity push tokens (one per pinned session) ───────────────────── +export interface LiveActivityReg { + sessionId: string; + /** The ActivityKit push token (hex) for this session's Live Activity. */ + token: string; + createdAt: number; +} +function liveActivitiesPath(): string { + return path.join(lisaHome(), "live-activities.json"); +} +export function listLiveActivities(): LiveActivityReg[] { + try { + const parsed: unknown = JSON.parse(fs.readFileSync(liveActivitiesPath(), "utf8")); + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (r): r is LiveActivityReg => + !!r && typeof (r as LiveActivityReg).sessionId === "string" && typeof (r as LiveActivityReg).token === "string", + ); + } catch { + return []; + } +} +export function registerLiveActivity(sessionId: string, token: string, now: number = Date.now()): void { + const list = listLiveActivities().filter((r) => r.sessionId !== sessionId); + list.push({ sessionId, token, createdAt: now }); + const file = liveActivitiesPath(); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(list, null, 2)); +} +export function unregisterLiveActivity(sessionId: string): boolean { + const list = listLiveActivities(); + const next = list.filter((r) => r.sessionId !== sessionId); + if (next.length === list.length) return false; + fs.writeFileSync(liveActivitiesPath(), JSON.stringify(next, null, 2)); + return true; +} + // ── pure trigger ────────────────────────────────────────────────────────── /** Custom URL scheme Lisa Pocket registers for push / widget deep-links. */ export const POCKET_SCHEME = "lisapocket"; @@ -305,11 +342,75 @@ export async function sendApns( return res.status === 200; } +// ── Live Activity remote updates (ActivityKit over APNs) ─────────────────── +export interface LiveActivityState { + state: string; + detail: string; + turns: number; +} + +/** Content-state for a session's Live Activity — mirrors the app's + * AgentActivityAttributes.ContentState + its detail() logic. Pure. */ +export function liveActivityState(s: AgentSession): LiveActivityState { + const a = s.activity; + const last = a?.lastTools && a.lastTools.length ? a.lastTools[a.lastTools.length - 1] : undefined; + const detail = a?.pendingPermission ? `⚠ ${a.pendingPermission}` : (s.stateReason || last || s.state); + return { state: s.state, detail, turns: a?.turnCount ?? 0 }; +} + +/** APNs Live Activity payload — `event:"update"` refreshes, `"end"` dismisses. Pure. */ +export function buildLiveActivityPayload( + cs: LiveActivityState, + event: "update" | "end", + nowSec: number, +): Record { + return { + aps: { + timestamp: nowSec, + event, + "content-state": { state: cs.state, detail: cs.detail, turns: cs.turns }, + ...(event === "end" ? { "dismissal-date": nowSec } : {}), + }, + }; +} + +/** Push a Live Activity update/end over APNs (push-type liveactivity). `post` + * injectable for tests. Reuses the cached provider JWT. */ +export async function sendLiveActivityUpdate( + cfg: ApnsConfig, + laToken: string, + cs: LiveActivityState, + event: "update" | "end", + post: ApnsPoster = realApnsPost, + nowSec: number = Math.floor(Date.now() / 1000), +): Promise { + const cacheId = `${cfg.keyId}/${cfg.teamId}`; + if (!cachedApnsJwt || cachedApnsJwt.id !== cacheId || nowSec - cachedApnsJwt.at > 50 * 60) { + cachedApnsJwt = { token: buildApnsJwt(cfg, nowSec), at: nowSec, id: cacheId }; + } + const res = await post({ + host: cfg.host, + path: `/3/device/${laToken}`, + headers: { + authorization: `bearer ${cachedApnsJwt.token}`, + "apns-topic": `${cfg.topic}.push-type.liveactivity`, + "apns-push-type": "liveactivity", + "apns-priority": "10", + }, + body: JSON.stringify(buildLiveActivityPayload(cs, event, nowSec)), + }); + return res.status === 200; +} + // ── bridge: hub/idle events → throttled per-subscription delivery ────────── export interface PushBridgeOpts { subs?: () => PushSubscription[]; /** Injected delivery (tests). Default: real ntfy / apns-stub. */ deliver?: (sub: PushSubscription, ev: PushEvent) => void | Promise; + /** Registered Live Activity tokens (tests). Default: the on-disk store. */ + liveActivities?: () => LiveActivityReg[]; + /** Injected Live Activity delivery (tests). Default: real APNs liveactivity. */ + liveDeliver?: (token: string, cs: LiveActivityState, event: "update" | "end") => void | Promise; now?: () => number; log?: (m: string) => void; throttleMs?: number; @@ -320,9 +421,12 @@ export class PushBridge { private readonly lastSent = new Map(); private readonly subs: () => PushSubscription[]; private readonly deliverFn: (sub: PushSubscription, ev: PushEvent) => void | Promise; + private readonly liveActivities: () => LiveActivityReg[]; + private readonly liveDeliverFn: (token: string, cs: LiveActivityState, event: "update" | "end") => void | Promise; private readonly now: () => number; private readonly log: (m: string) => void; private readonly throttleMs: number; + private readonly liveThrottleMs = 3000; constructor(opts: PushBridgeOpts = {}) { this.subs = opts.subs ?? listPush; @@ -330,6 +434,8 @@ export class PushBridge { this.log = opts.log ?? (() => {}); this.throttleMs = opts.throttleMs ?? 30_000; this.deliverFn = opts.deliver ?? ((sub, ev) => this.defaultDeliver(sub, ev)); + this.liveActivities = opts.liveActivities ?? listLiveActivities; + this.liveDeliverFn = opts.liveDeliver ?? ((token, cs, event) => this.defaultLiveDeliver(token, cs, event)); } onAgentUpdate(next: AgentSession): void { @@ -337,6 +443,21 @@ export class PushBridge { const events = agentPushEvents(this.prev.get(key), next); this.prev.set(key, next); for (const ev of events) this.fire(ev, `${key}#${ev.tag}`); + this.updateLiveActivity(next); + } + + /** Refresh a pinned session's Live Activity (throttled), ending it on a + * terminal state. No-op unless an LA token is registered for the session. */ + private updateLiveActivity(next: AgentSession): void { + const reg = this.liveActivities().find((r) => r.sessionId === next.sessionId); + if (!reg) return; + const terminal = next.state === "done" || next.state === "error"; + const key = `la#${next.sessionId}`; + // Throttle progress refreshes, but always let a terminal "end" through. + if (!terminal && this.now() - (this.lastSent.get(key) ?? -Infinity) < this.liveThrottleMs) return; + this.lastSent.set(key, this.now()); + void Promise.resolve(this.liveDeliverFn(reg.token, liveActivityState(next), terminal ? "end" : "update")).catch(() => {}); + if (terminal) unregisterLiveActivity(next.sessionId); } onIdleMessage(text: string): void { @@ -369,4 +490,14 @@ export class PushBridge { if (!ok) this.log(`[push] apns send failed for ${sub.id}`); } } + + private async defaultLiveDeliver(token: string, cs: LiveActivityState, event: "update" | "end"): Promise { + const cfg = apnsConfigFromEnv(); + if (!cfg) { + this.log(`[push] live-activity ${event} skipped (no APNs key)`); + return; + } + const ok = await sendLiveActivityUpdate(cfg, token, cs, event); + if (!ok) this.log(`[push] live-activity ${event} failed`); + } } diff --git a/src/web/server.ts b/src/web/server.ts index 0a85f1b..9b74aec 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -42,7 +42,7 @@ import { liveClaudeSessionIds } from "../integrations/claude-code/liveness.js"; import { listRecentDispatches, isAlive, toDispatchView, readDispatchOutput } from "../integrations/dispatch-ledger.js"; import { loadControlPolicy, saveControlPolicy, type ControlPolicy } from "../control/policy.js"; import { mintDevice, verifyDeviceToken, touchDevice, listDevices, revokeDevice } from "./devices.js"; -import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs, type PushPrefs } from "./push.js"; +import { PushBridge, listPush, registerPush, unregisterPush, setPushPrefs, registerLiveActivity, unregisterLiveActivity, type PushPrefs } from "./push.js"; import { SenseService } from "../sense/service.js"; import { ScreenSource } from "../sense/screen.js"; import { VoiceSource } from "../sense/voice.js"; @@ -891,6 +891,25 @@ export async function startWebServer(opts: WebServerOptions): Promise