Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions packaging/ios-companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packaging/ios-companion/Sources/LisaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
21 changes: 16 additions & 5 deletions packaging/ios-companion/Sources/LiveActivityController.swift
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packaging/ios-companion/Sources/RosterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
Expand Down
73 changes: 73 additions & 0 deletions src/web/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ const {
buildApnsJwt,
buildApnsPayload,
sendApns,
liveActivityState,
buildLiveActivityPayload,
sendLiveActivityUpdate,
registerLiveActivity,
unregisterLiveActivity,
listLiveActivities,
PushBridge,
registerPush,
unregisterPush,
Expand Down Expand Up @@ -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<string, unknown>;
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<string, unknown>)["dismissal-date"], 1000);
});

test("sendLiveActivityUpdate: liveactivity push-type + topic suffix", async () => {
let captured: { path: string; headers: Record<string, string>; 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" },
]);
});
});
131 changes: 131 additions & 0 deletions src/web/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,43 @@ export function setPushPrefs(id: string, prefs: Partial<PushPrefs>): 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";
Expand Down Expand Up @@ -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<string, unknown> {
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<boolean> {
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<void>;
/** 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<void>;
now?: () => number;
log?: (m: string) => void;
throttleMs?: number;
Expand All @@ -320,23 +421,43 @@ export class PushBridge {
private readonly lastSent = new Map<string, number>();
private readonly subs: () => PushSubscription[];
private readonly deliverFn: (sub: PushSubscription, ev: PushEvent) => void | Promise<void>;
private readonly liveActivities: () => LiveActivityReg[];
private readonly liveDeliverFn: (token: string, cs: LiveActivityState, event: "update" | "end") => void | Promise<void>;
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;
this.now = opts.now ?? Date.now;
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 {
const key = `${next.agent}/${next.sessionId}`;
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 {
Expand Down Expand Up @@ -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<void> {
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`);
}
}
21 changes: 20 additions & 1 deletion src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -891,6 +891,25 @@ export async function startWebServer(opts: WebServerOptions): Promise<http.Serve
res.end(JSON.stringify({ ok: removed }));
return;
}
// Register/unregister a Live Activity push token for a pinned session — the
// push-bridge then refreshes that activity over APNs as the agent updates.
if (req.method === "POST" && url === "/api/push/live-activity") {
let laBody = "";
for await (const chunk of req) laBody += chunk.toString("utf8");
let payload: { sessionId?: unknown; token?: unknown } = {};
try { payload = laBody ? JSON.parse(laBody) : {}; } catch { /* tolerate */ }
if (typeof payload.sessionId !== "string" || !payload.sessionId) {
res.writeHead(400, { "content-type": "text/plain" }); res.end("sessionId required"); return;
}
if (typeof payload.token === "string" && payload.token) {
registerLiveActivity(payload.sessionId, payload.token);
} else {
unregisterLiveActivity(payload.sessionId);
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === "GET" && url === "/api/push/list") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ subscriptions: listPush() }));
Expand Down