diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 7f1834f75..e2ee8fb35 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -17,6 +17,7 @@ "@capacitor/browser": "^8.0.3", "@capacitor/core": "^8.3.0", "@capacitor/ios": "^8.3.0", + "@capacitor/local-notifications": "^8.0.1", "capacitor-secure-storage-plugin": "^0.13.0" }, "devDependencies": { diff --git a/apps/mobile/src/mobile-bridge.ts b/apps/mobile/src/mobile-bridge.ts index 6eb2515fa..6de5c5bed 100644 --- a/apps/mobile/src/mobile-bridge.ts +++ b/apps/mobile/src/mobile-bridge.ts @@ -1,16 +1,28 @@ import { App } from "@capacitor/app"; import { Browser } from "@capacitor/browser"; -import type { MobileBridge, MobilePairingState } from "@okcode/contracts"; +import type { + MobileBridge, + MobileConnectionState, + MobileNotificationEvent, + MobilePairingState, +} from "@okcode/contracts"; import { SecureStoragePlugin } from "capacitor-secure-storage-plugin"; import { parseMobilePairingInput } from "./mobilePairing"; +import { + fireNotification as fireLocalNotification, + registerNotifications as registerLocalNotifications, + setupNotificationTapHandler, +} from "./notifications"; const STORAGE_KEYS = { serverUrl: "okcode.mobile.serverUrl", token: "okcode.mobile.authToken", } as const; -const listeners = new Set<(state: MobilePairingState) => void>(); +// ── Pairing state ──────────────────────────────────────────────────── + +const pairingListeners = new Set<(state: MobilePairingState) => void>(); let pairingState: MobilePairingState = { paired: false, @@ -39,7 +51,7 @@ async function removeSecureValue(key: string): Promise { } function emitPairingState(): void { - for (const listener of listeners) { + for (const listener of pairingListeners) { try { listener(pairingState); } catch { @@ -161,6 +173,58 @@ async function clearPairing(): Promise { ); } +// ── Connection state ───────────────────────────────────────────────── + +const connectionListeners = new Set<(state: MobileConnectionState) => void>(); +let connectionState: MobileConnectionState = "disconnected"; + +function setConnectionState(nextState: MobileConnectionState): void { + if (connectionState === nextState) return; + connectionState = nextState; + for (const listener of connectionListeners) { + try { + listener(connectionState); + } catch { + // Swallow listener errors. + } + } +} + +// The web app's WsTransport emits state changes. The bridge listens to a +// custom event that the web layer fires so the native side can track it. +if (typeof window !== "undefined") { + window.addEventListener("okcode:transport-state", ((event: CustomEvent) => { + const state = event.detail; + switch (state) { + case "open": + setConnectionState("connected"); + break; + case "connecting": + setConnectionState("connecting"); + break; + case "reconnecting": + setConnectionState("reconnecting"); + break; + case "closed": + case "disposed": + setConnectionState("disconnected"); + break; + } + }) as EventListener); +} + +// ── Notification tap handler ───────────────────────────────────────── + +setupNotificationTapHandler((threadId) => { + if (threadId && typeof window !== "undefined") { + // Navigate to the thread when a notification is tapped. + // The web app listens for this custom event and handles navigation. + window.dispatchEvent(new CustomEvent("okcode:notification-tap", { detail: { threadId } })); + } +}); + +// ── Bridge export ──────────────────────────────────────────────────── + const mobileBridge: MobileBridge = { getWsUrl: () => websocketUrl, getPairingState: async () => { @@ -184,12 +248,32 @@ const mobileBridge: MobileBridge = { } }, onPairingState: (listener) => { - listeners.add(listener); + pairingListeners.add(listener); listener(pairingState); return () => { - listeners.delete(listener); + pairingListeners.delete(listener); + }; + }, + + // ── Phase 3 additions ────────────────────────────────────────── + + getConnectionState: () => connectionState, + + onConnectionState: (listener) => { + connectionListeners.add(listener); + listener(connectionState); + return () => { + connectionListeners.delete(listener); }; }, + + registerNotifications: async () => { + return registerLocalNotifications(); + }, + + fireNotification: async (event: MobileNotificationEvent) => { + return fireLocalNotification(event); + }, }; Object.defineProperty(window, "mobileBridge", { diff --git a/apps/mobile/src/notifications.ts b/apps/mobile/src/notifications.ts new file mode 100644 index 000000000..2156e6a07 --- /dev/null +++ b/apps/mobile/src/notifications.ts @@ -0,0 +1,108 @@ +/** + * Local notification support for the mobile companion app. + * + * Uses Capacitor Local Notifications to alert the user when attention-requiring + * events arrive while the app is backgrounded. + */ +import { LocalNotifications } from "@capacitor/local-notifications"; +import type { MobileNotificationEvent } from "@okcode/contracts"; + +let registered = false; +let nextNotificationId = 1; + +const CATEGORY_CHANNEL_MAP: Record = { + "approval-requested": "attention", + "user-input-requested": "attention", + "turn-completed": "status", + "session-error": "alerts", +}; + +/** + * Request notification permissions and create notification channels. + * Returns true if permission was granted. + */ +export async function registerNotifications(): Promise { + if (registered) return true; + + try { + const permission = await LocalNotifications.requestPermissions(); + if (permission.display !== "granted") { + return false; + } + + // Create Android notification channels (no-op on iOS) + await LocalNotifications.createChannel({ + id: "attention", + name: "Attention Required", + description: "Approval requests and user input needed", + importance: 5, // Max importance + sound: "default", + vibration: true, + }); + + await LocalNotifications.createChannel({ + id: "status", + name: "Status Updates", + description: "Turn completions and session updates", + importance: 3, // Default importance + sound: "default", + vibration: false, + }); + + await LocalNotifications.createChannel({ + id: "alerts", + name: "Alerts", + description: "Errors and critical session events", + importance: 4, // High importance + sound: "default", + vibration: true, + }); + + registered = true; + return true; + } catch { + return false; + } +} + +/** + * Fire a local notification for a mobile notification event. + */ +export async function fireNotification(event: MobileNotificationEvent): Promise { + if (!registered) { + const ok = await registerNotifications(); + if (!ok) return; + } + + const channelId = CATEGORY_CHANNEL_MAP[event.category] ?? "attention"; + const id = nextNotificationId++; + + await LocalNotifications.schedule({ + notifications: [ + { + id, + title: event.title, + body: event.body, + channelId, + extra: { + eventId: event.id, + category: event.category, + threadId: event.threadId, + occurredAt: event.occurredAt, + }, + // Show immediately + schedule: { at: new Date(Date.now() + 100) }, + }, + ], + }); +} + +/** + * Set up listener for notification taps to enable deep navigation. + */ +export function setupNotificationTapHandler(onTap: (threadId: string | undefined) => void): void { + void LocalNotifications.addListener("localNotificationActionPerformed", (action) => { + const threadId = action.notification.extra?.threadId as string | undefined; + onTap(threadId); + }); +} diff --git a/apps/server/src/tokenManager.test.ts b/apps/server/src/tokenManager.test.ts new file mode 100644 index 000000000..f33fa08c2 --- /dev/null +++ b/apps/server/src/tokenManager.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import { TokenManager } from "./tokenManager"; + +describe("TokenManager", () => { + describe("validate", () => { + it("accepts any token when auth is disabled", () => { + const manager = new TokenManager(undefined); + expect(manager.validate(null)).toBe(true); + expect(manager.validate("anything")).toBe(true); + }); + + it("accepts the initial token", () => { + const manager = new TokenManager("secret-token"); + expect(manager.validate("secret-token")).toBe(true); + }); + + it("rejects an incorrect token", () => { + const manager = new TokenManager("secret-token"); + expect(manager.validate("wrong-token")).toBe(false); + }); + + it("rejects null token when auth is enabled", () => { + const manager = new TokenManager("secret-token"); + expect(manager.validate(null)).toBe(false); + }); + }); + + describe("generatePairingToken", () => { + it("creates a short-lived token that validates", () => { + const manager = new TokenManager("initial"); + const record = manager.generatePairingToken({ ttlSeconds: 60 }); + expect(record.kind).toBe("short-lived"); + expect(record.expiresAt).not.toBeNull(); + expect(manager.validate(record.tokenValue)).toBe(true); + }); + + it("defaults to 5 minute TTL", () => { + const manager = new TokenManager("initial"); + const record = manager.generatePairingToken(); + const expiresAt = new Date(record.expiresAt!).getTime(); + const createdAt = new Date(record.createdAt).getTime(); + // Allow 1 second tolerance + expect(expiresAt - createdAt).toBeGreaterThanOrEqual(299_000); + expect(expiresAt - createdAt).toBeLessThanOrEqual(301_000); + }); + }); + + describe("rotate", () => { + it("issues a new token and returns the previous token ID", () => { + const manager = new TokenManager("initial"); + const tokens = manager.list(); + const initialTokenId = tokens[0]!.tokenId; + + const result = manager.rotate(); + expect(result.previousTokenId).toBe(initialTokenId); + expect(result.newRecord.kind).toBe("long-lived"); + expect(manager.validate(result.newRecord.tokenValue)).toBe(true); + }); + + it("old token is still valid during grace period", () => { + const manager = new TokenManager("initial"); + manager.rotate(); + // The old token should still work during the 30s grace period + expect(manager.validate("initial")).toBe(true); + }); + }); + + describe("revoke", () => { + it("immediately invalidates a token", () => { + const manager = new TokenManager("initial"); + const record = manager.generatePairingToken(); + expect(manager.validate(record.tokenValue)).toBe(true); + + manager.revoke(record.tokenId); + expect(manager.validate(record.tokenValue)).toBe(false); + }); + + it("returns false for unknown token ID", () => { + const manager = new TokenManager("initial"); + expect(manager.revoke("nonexistent")).toBe(false); + }); + }); + + describe("list", () => { + it("lists all tokens without exposing values", () => { + const manager = new TokenManager("initial"); + manager.generatePairingToken({ label: "test-pairing" }); + + const tokens = manager.list(); + expect(tokens).toHaveLength(2); + expect(tokens.some((t) => t.kind === "long-lived")).toBe(true); + expect(tokens.some((t) => t.kind === "short-lived")).toBe(true); + // Values should not be present in list output + for (const token of tokens) { + expect(token).not.toHaveProperty("tokenValue"); + } + }); + }); + + describe("getPrimaryTokenValue", () => { + it("returns the primary token value", () => { + const manager = new TokenManager("initial"); + expect(manager.getPrimaryTokenValue()).toBe("initial"); + }); + + it("returns null when auth is disabled", () => { + const manager = new TokenManager(undefined); + expect(manager.getPrimaryTokenValue()).toBeNull(); + }); + + it("returns the new value after rotation", () => { + const manager = new TokenManager("initial"); + const { newRecord } = manager.rotate(); + expect(manager.getPrimaryTokenValue()).toBe(newRecord.tokenValue); + }); + }); +}); diff --git a/apps/server/src/tokenManager.ts b/apps/server/src/tokenManager.ts new file mode 100644 index 000000000..4377a72cd --- /dev/null +++ b/apps/server/src/tokenManager.ts @@ -0,0 +1,199 @@ +/** + * TokenManager - Token lifecycle management for mobile pairing. + * + * Manages long-lived auth tokens and short-lived pairing tokens with + * rotation, revocation, and expiry. + * + * @module TokenManager + */ +import crypto from "node:crypto"; + +export interface TokenRecord { + readonly tokenId: string; + readonly tokenValue: string; + readonly kind: "long-lived" | "short-lived"; + readonly createdAt: string; + readonly expiresAt: string | null; + readonly label: string | undefined; + revoked: boolean; +} + +/** + * Generate a cryptographically random token string. + */ +function generateToken(): string { + return crypto.randomBytes(32).toString("base64url"); +} + +function generateTokenId(): string { + return crypto.randomUUID(); +} + +/** + * TokenManager handles token creation, rotation, revocation, and validation. + * + * The manager wraps the single process-level `authToken` from the server config + * and extends it with support for: + * - Short-lived pairing tokens (for QR code / bootstrap link flows) + * - Token rotation (issue a new long-lived token, grace-period the old one) + * - Token revocation (immediately invalidate a specific token) + */ +export class TokenManager { + private tokens = new Map(); + private primaryTokenId: string | null = null; + + /** + * Seed the manager with the initial auth token from server config. + * If `initialToken` is undefined, auth is disabled and all tokens are accepted. + */ + constructor(private initialToken: string | undefined) { + if (initialToken) { + const tokenId = generateTokenId(); + this.tokens.set(tokenId, { + tokenId, + tokenValue: initialToken, + kind: "long-lived", + createdAt: new Date().toISOString(), + expiresAt: null, + label: "initial", + revoked: false, + }); + this.primaryTokenId = tokenId; + } + } + + /** + * Check whether a provided token value is valid (matches any non-revoked, + * non-expired token). Returns true if auth is disabled (no initial token). + */ + validate(providedToken: string | null): boolean { + if (!this.initialToken && this.tokens.size === 0) { + return true; // Auth disabled + } + if (!providedToken) { + return false; + } + + const now = Date.now(); + for (const record of this.tokens.values()) { + if (record.revoked) continue; + if (record.expiresAt && new Date(record.expiresAt).getTime() <= now) continue; + if (record.tokenValue === providedToken) return true; + } + return false; + } + + /** + * Generate a short-lived pairing token. + */ + generatePairingToken(options?: { + ttlSeconds?: number | undefined; + label?: string | undefined; + }): TokenRecord { + const ttl = options?.ttlSeconds ?? 300; // Default 5 minutes + const tokenId = generateTokenId(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + ttl * 1000); + + const record: TokenRecord = { + tokenId, + tokenValue: generateToken(), + kind: "short-lived", + createdAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + label: options?.label, + revoked: false, + }; + this.tokens.set(tokenId, record); + return record; + } + + /** + * Rotate the primary long-lived token. Issues a new token and revokes the old + * one after a short grace period (30 seconds) to allow in-flight connections + * to complete their handshake. + */ + rotate(): { previousTokenId: string | null; newRecord: TokenRecord } { + const previousTokenId = this.primaryTokenId; + + // Issue new token + const tokenId = generateTokenId(); + const now = new Date(); + const newRecord: TokenRecord = { + tokenId, + tokenValue: generateToken(), + kind: "long-lived", + createdAt: now.toISOString(), + expiresAt: null, + label: undefined, + revoked: false, + }; + this.tokens.set(tokenId, newRecord); + this.primaryTokenId = tokenId; + + // Grace-period revoke the old token (30s) + if (previousTokenId) { + const oldRecord = this.tokens.get(previousTokenId); + if (oldRecord && !oldRecord.revoked) { + setTimeout(() => { + oldRecord.revoked = true; + }, 30_000); + } + } + + return { previousTokenId, newRecord }; + } + + /** + * Immediately revoke a specific token by ID. + */ + revoke(tokenId: string): boolean { + const record = this.tokens.get(tokenId); + if (!record) return false; + record.revoked = true; + return true; + } + + /** + * List all tokens (values are masked for security). + */ + list(): Array<{ + tokenId: string; + kind: "long-lived" | "short-lived"; + createdAt: string; + expiresAt: string | null; + revoked: boolean; + label: string | undefined; + }> { + this.pruneExpired(); + return Array.from(this.tokens.values()).map((record) => ({ + tokenId: record.tokenId, + kind: record.kind, + createdAt: record.createdAt, + expiresAt: record.expiresAt, + revoked: record.revoked, + label: record.label, + })); + } + + /** + * Get the current primary token value (for building pairing URLs). + */ + getPrimaryTokenValue(): string | null { + if (!this.primaryTokenId) return null; + const record = this.tokens.get(this.primaryTokenId); + return record && !record.revoked ? record.tokenValue : null; + } + + /** + * Remove expired short-lived tokens to avoid unbounded growth. + */ + private pruneExpired(): void { + const now = Date.now(); + for (const [id, record] of this.tokens) { + if (record.expiresAt && new Date(record.expiresAt).getTime() <= now && record.revoked) { + this.tokens.delete(id); + } + } + } +} diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 8233b549e..3ded330ad 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -87,6 +87,7 @@ import { PrReview } from "./prReview/Services/PrReview.ts"; import { GitActionExecutionError } from "./git/Errors.ts"; import { EnvironmentVariables } from "./persistence/Services/EnvironmentVariables.ts"; import { SkillService } from "./skills/SkillService.ts"; +import { TokenManager } from "./tokenManager.ts"; import { resolveRuntimeEnvironment } from "./runtimeEnvironment.ts"; import { version as serverVersion } from "../package.json" with { type: "json" }; @@ -321,6 +322,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< autoBootstrapProjectFromCwd, } = serverConfig; const availableEditors = resolveAvailableEditors(); + const tokenManager = new TokenManager(authToken); const gitManager = yield* GitManager; const terminalManager = yield* TerminalManager; @@ -535,6 +537,34 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } + // ── Pairing API endpoint ────────────────────────────────── + if (url.pathname === "/api/pairing" && req.method === "GET") { + if (!authToken) { + respond( + 200, + { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + JSON.stringify({ error: "Auth is not enabled on this server." }), + ); + return; + } + + const ttlParam = url.searchParams.get("ttl"); + const ttlSeconds = ttlParam ? Math.min(Math.max(Number(ttlParam), 30), 3600) : 300; + const record = tokenManager.generatePairingToken({ ttlSeconds, label: "http-api" }); + const serverUrl = `http://${host ?? "localhost"}:${port}`; + const pairingUrl = `okcode://pair?server=${encodeURIComponent(serverUrl)}&token=${encodeURIComponent(record.tokenValue)}`; + respond( + 200, + { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }, + JSON.stringify({ + pairingUrl, + expiresAt: record.expiresAt, + serverUrl, + }), + ); + return; + } + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); @@ -1321,6 +1351,42 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { path: pickedPath }; } + case WS_METHODS.serverGeneratePairingLink: { + const body = stripRequestTag(request.body); + const record = tokenManager.generatePairingToken({ + ttlSeconds: body.ttlSeconds, + label: body.label, + }); + const serverUrl = `http://${host ?? "localhost"}:${port}`; + const pairingUrl = `okcode://pair?server=${encodeURIComponent(serverUrl)}&token=${encodeURIComponent(record.tokenValue)}`; + return { + pairingUrl, + token: record.tokenValue, + expiresAt: record.expiresAt!, + }; + } + + case WS_METHODS.serverRotateToken: { + const { previousTokenId, newRecord } = tokenManager.rotate(); + return { + previousTokenId, + newToken: newRecord.tokenValue, + newTokenId: newRecord.tokenId, + issuedAt: newRecord.createdAt, + }; + } + + case WS_METHODS.serverRevokeToken: { + const body = stripRequestTag(request.body); + const revoked = tokenManager.revoke(body.tokenId); + return { tokenId: body.tokenId, revoked }; + } + + case WS_METHODS.serverListTokens: { + const tokens = tokenManager.list(); + return { tokens }; + } + case WS_METHODS.skillList: { const body = stripRequestTag(request.body); return yield* skillService.list(body); @@ -1448,7 +1514,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - if (providedToken !== authToken) { + if (!tokenManager.validate(providedToken)) { rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); return; } diff --git a/apps/web/src/components/mobile/MobileConnectionBanner.tsx b/apps/web/src/components/mobile/MobileConnectionBanner.tsx new file mode 100644 index 000000000..fc20f0e01 --- /dev/null +++ b/apps/web/src/components/mobile/MobileConnectionBanner.tsx @@ -0,0 +1,64 @@ +import type { MobileConnectionState } from "@okcode/contracts"; + +import { useMobileConnectionState } from "../../hooks/useMobileConnectionState"; + +const STATE_CONFIG: Record< + MobileConnectionState, + { message: string; className: string; visible: boolean } +> = { + connected: { + message: "", + className: "", + visible: false, + }, + connecting: { + message: "Connecting to server...", + className: + "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-800", + visible: true, + }, + reconnecting: { + message: "Connection lost. Reconnecting...", + className: + "bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-800", + visible: true, + }, + disconnected: { + message: "Disconnected from server. Check your network connection.", + className: + "bg-red-50 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-800", + visible: true, + }, +}; + +/** + * A banner that displays the current connection state when running in + * the mobile companion shell. Hidden when connected. + */ +export function MobileConnectionBanner() { + const connectionState = useMobileConnectionState(); + + if (!connectionState) return null; + + const config = STATE_CONFIG[connectionState]; + if (!config.visible) return null; + + return ( +
+ {connectionState === "reconnecting" && ( + + )} + {connectionState === "connecting" && ( + + )} + {connectionState === "disconnected" && ( + + )} + {config.message} +
+ ); +} diff --git a/apps/web/src/components/mobile/PairingQrCode.tsx b/apps/web/src/components/mobile/PairingQrCode.tsx new file mode 100644 index 000000000..54ee49ad3 --- /dev/null +++ b/apps/web/src/components/mobile/PairingQrCode.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from "react"; + +import { generateQrSvg } from "../../lib/qrCode"; +import { resolveServerHttpOrigin } from "../../lib/runtimeBridge"; +import { Button } from "../ui/button"; + +interface PairingInfo { + pairingUrl: string; + expiresAt: string; + serverUrl: string; +} + +/** + * PairingQrCode renders a QR code on the desktop web app that mobile devices + * can scan to pair. It fetches a short-lived pairing link from the server's + * `/api/pairing` endpoint and displays it as a scannable QR code. + * + * The QR code auto-refreshes when the pairing link expires. + */ +export function PairingQrCode() { + const [pairing, setPairing] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [svgHtml, setSvgHtml] = useState(null); + const [expiresIn, setExpiresIn] = useState(null); + + const fetchPairingLink = useCallback(async () => { + setLoading(true); + setError(null); + try { + const origin = resolveServerHttpOrigin(); + const response = await fetch(`${origin}/api/pairing?ttl=300`); + if (!response.ok) { + throw new Error(`Server returned ${response.status}`); + } + const data = (await response.json()) as PairingInfo; + if ("error" in data) { + setError(data.error as unknown as string); + setPairing(null); + setSvgHtml(null); + return; + } + setPairing(data); + setSvgHtml(generateQrSvg(data.pairingUrl)); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to generate pairing link."); + setPairing(null); + setSvgHtml(null); + } finally { + setLoading(false); + } + }, []); + + // Fetch on mount + useEffect(() => { + void fetchPairingLink(); + }, [fetchPairingLink]); + + // Countdown timer + useEffect(() => { + if (!pairing?.expiresAt) { + setExpiresIn(null); + return; + } + + const update = () => { + const remaining = Math.max( + 0, + Math.floor((new Date(pairing.expiresAt).getTime() - Date.now()) / 1000), + ); + setExpiresIn(remaining); + if (remaining <= 0) { + // Auto-refresh when expired + void fetchPairingLink(); + } + }; + + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [pairing?.expiresAt, fetchPairingLink]); + + const formatTime = (seconds: number) => { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, "0")}`; + }; + + return ( +
+

Scan with OK Code mobile app

+ + {error ? ( +
+

{error}

+ +
+ ) : svgHtml ? ( + <> +
+ {expiresIn !== null && ( +

+ {expiresIn > 0 ? <>Expires in {formatTime(expiresIn)} : <>Refreshing...} +

+ )} + + {pairing?.pairingUrl && ( +
+ + Show pairing link + + + {pairing.pairingUrl} + +
+ )} + + ) : loading ? ( +
+

Generating...

+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/hooks/useMobileConnectionState.ts b/apps/web/src/hooks/useMobileConnectionState.ts new file mode 100644 index 000000000..848360265 --- /dev/null +++ b/apps/web/src/hooks/useMobileConnectionState.ts @@ -0,0 +1,30 @@ +import type { MobileConnectionState } from "@okcode/contracts"; +import { useEffect, useState } from "react"; + +import { readMobileBridge } from "../lib/runtimeBridge"; + +/** + * React hook that tracks the mobile companion connection state. + * Returns null when not running in the mobile shell. + */ +export function useMobileConnectionState(): MobileConnectionState | null { + const mobileBridge = readMobileBridge(); + const [state, setState] = useState(null); + + useEffect(() => { + if (!mobileBridge) { + setState(null); + return; + } + + const unsubscribe = mobileBridge.onConnectionState((nextState) => { + setState(nextState); + }); + + return () => { + unsubscribe(); + }; + }, [mobileBridge]); + + return mobileBridge ? state : null; +} diff --git a/apps/web/src/lib/mobileNotifications.ts b/apps/web/src/lib/mobileNotifications.ts new file mode 100644 index 000000000..15418c1e9 --- /dev/null +++ b/apps/web/src/lib/mobileNotifications.ts @@ -0,0 +1,130 @@ +/** + * Mobile notification bridge. + * + * Listens to orchestration domain events over the WebSocket push channel and + * fires local notifications via the mobile bridge when the app is backgrounded + * and an attention-requiring event arrives. + * + * This module is side-effect free until `initMobileNotifications` is called. + */ +import type { MobileNotificationEvent } from "@okcode/contracts"; +import { ORCHESTRATION_WS_CHANNELS } from "@okcode/contracts"; + +import { readMobileBridge } from "./runtimeBridge"; +import type { WsTransport } from "../wsTransport"; + +let initialized = false; + +/** + * Returns true when the document is hidden (app is backgrounded / tab is not + * visible). Notifications are only fired when the user is not actively looking + * at the app. + */ +function isAppBackgrounded(): boolean { + return typeof document !== "undefined" && document.visibilityState === "hidden"; +} + +/** + * Derive a notification event from an orchestration domain event, or null if + * the event does not warrant a notification. + */ +function deriveNotification(event: { + type: string; + payload?: Record; + eventId?: string; + occurredAt?: string; +}): MobileNotificationEvent | null { + const eventId = (event.eventId as string) ?? crypto.randomUUID(); + const occurredAt = (event.occurredAt as string) ?? new Date().toISOString(); + const threadId = (event.payload?.threadId as string) ?? undefined; + + switch (event.type) { + case "thread.approval-response-requested": + return { + id: eventId, + category: "approval-requested", + title: "Approval Requested", + body: "An agent action needs your approval.", + threadId, + occurredAt, + }; + + case "thread.user-input-response-requested": + return { + id: eventId, + category: "user-input-requested", + title: "Input Needed", + body: "The agent is waiting for your input.", + threadId, + occurredAt, + }; + + case "thread.turn-diff-completed": + return { + id: eventId, + category: "turn-completed", + title: "Turn Completed", + body: "The agent has finished a turn.", + threadId, + occurredAt, + }; + + case "thread.session-set": { + const status = (event.payload as Record)?.status; + if ( + typeof status === "object" && + status !== null && + (status as Record).status === "error" + ) { + return { + id: eventId, + category: "session-error", + title: "Session Error", + body: "An agent session encountered an error.", + threadId, + occurredAt, + }; + } + return null; + } + + default: + return null; + } +} + +/** + * Initialize the mobile notification listener. Should be called once during + * app startup when running in the mobile shell. + */ +export function initMobileNotifications(transport: WsTransport): () => void { + const mobileBridge = readMobileBridge(); + if (!mobileBridge || initialized) { + return () => {}; + } + + initialized = true; + + // Register for notification permissions eagerly. + void mobileBridge.registerNotifications(); + + const unsubscribe = transport.subscribe(ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { + if (!isAppBackgrounded()) return; + + const event = push.data as { + type: string; + payload?: Record; + eventId?: string; + occurredAt?: string; + }; + const notification = deriveNotification(event); + if (notification) { + void mobileBridge.fireNotification(notification); + } + }); + + return () => { + initialized = false; + unsubscribe(); + }; +} diff --git a/apps/web/src/lib/qrCode.ts b/apps/web/src/lib/qrCode.ts new file mode 100644 index 000000000..cbe9adc97 --- /dev/null +++ b/apps/web/src/lib/qrCode.ts @@ -0,0 +1,445 @@ +/** + * Minimal QR Code generator for pairing URLs. + * + * Encodes a string into a QR code matrix and renders it as an SVG data URL. + * Uses alphanumeric encoding where possible, byte mode otherwise. + * Supports version 1-10 with error correction level L for maximum data capacity. + * + * This is intentionally a simplified implementation sufficient for encoding + * pairing URLs (typically under 200 characters). For production-grade QR + * generation with full spec compliance, consider a dedicated library. + */ + +// ── Galois Field GF(256) arithmetic ────────────────────────────────── + +const EXP_TABLE = new Uint8Array(256); +const LOG_TABLE = new Uint8Array(256); + +(() => { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP_TABLE[i] = x; + LOG_TABLE[x] = i; + x = x << 1; + if (x & 0x100) x ^= 0x11d; + } + EXP_TABLE[255] = EXP_TABLE[0]!; +})(); + +function gfMul(a: number, b: number): number { + if (a === 0 || b === 0) return 0; + return EXP_TABLE[(LOG_TABLE[a]! + LOG_TABLE[b]!) % 255]!; +} + +function generateECBytes(data: Uint8Array, ecCount: number): Uint8Array { + // Build generator polynomial + const gen = new Uint8Array(ecCount + 1); + gen[0] = 1; + for (let i = 0; i < ecCount; i++) { + for (let j = i + 1; j >= 1; j--) { + gen[j] = gen[j]! ^ gfMul(gen[j - 1]!, EXP_TABLE[i]!); + } + } + + const result = new Uint8Array(ecCount); + for (let i = 0; i < data.length; i++) { + const coef = data[i]! ^ result[0]!; + result.copyWithin(0, 1); + result[ecCount - 1] = 0; + if (coef !== 0) { + for (let j = 0; j < ecCount; j++) { + result[j] = result[j]! ^ gfMul(gen[j + 1]!, coef); + } + } + } + return result; +} + +// ── QR Code version parameters (L error correction) ───────────────── + +interface VersionInfo { + version: number; + size: number; + dataBytes: number; + ecBytesPerBlock: number; + group1Blocks: number; + group1DataBytes: number; + group2Blocks: number; + group2DataBytes: number; +} + +const VERSIONS: VersionInfo[] = [ + { + version: 1, + size: 21, + dataBytes: 19, + ecBytesPerBlock: 7, + group1Blocks: 1, + group1DataBytes: 19, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 2, + size: 25, + dataBytes: 34, + ecBytesPerBlock: 10, + group1Blocks: 1, + group1DataBytes: 34, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 3, + size: 29, + dataBytes: 55, + ecBytesPerBlock: 15, + group1Blocks: 1, + group1DataBytes: 55, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 4, + size: 33, + dataBytes: 80, + ecBytesPerBlock: 20, + group1Blocks: 1, + group1DataBytes: 80, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 5, + size: 37, + dataBytes: 108, + ecBytesPerBlock: 26, + group1Blocks: 1, + group1DataBytes: 108, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 6, + size: 41, + dataBytes: 136, + ecBytesPerBlock: 18, + group1Blocks: 2, + group1DataBytes: 68, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 7, + size: 45, + dataBytes: 156, + ecBytesPerBlock: 20, + group1Blocks: 2, + group1DataBytes: 78, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 8, + size: 49, + dataBytes: 194, + ecBytesPerBlock: 24, + group1Blocks: 2, + group1DataBytes: 97, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 9, + size: 53, + dataBytes: 232, + ecBytesPerBlock: 30, + group1Blocks: 2, + group1DataBytes: 116, + group2Blocks: 0, + group2DataBytes: 0, + }, + { + version: 10, + size: 57, + dataBytes: 274, + ecBytesPerBlock: 18, + group1Blocks: 2, + group1DataBytes: 68, + group2Blocks: 2, + group2DataBytes: 69, + }, +]; + +function selectVersion(byteLength: number): VersionInfo { + // Data capacity = dataBytes - mode/length overhead (4 bits mode + 8/16 bits length) + for (const v of VERSIONS) { + const overhead = v.version >= 10 ? 3 : 2; // mode indicator + char count indicator + if (v.dataBytes - overhead >= byteLength) return v; + } + throw new Error(`Data too long for QR code (${byteLength} bytes)`); +} + +// ── Data encoding (byte mode) ──────────────────────────────────────── + +function encodeData(text: string, version: VersionInfo): Uint8Array { + const textBytes = new TextEncoder().encode(text); + const dataBytes = new Uint8Array(version.dataBytes); + let bitPos = 0; + + function writeBits(value: number, length: number) { + for (let i = length - 1; i >= 0; i--) { + if (value & (1 << i)) { + dataBytes[bitPos >> 3] = dataBytes[bitPos >> 3]! | (0x80 >> (bitPos & 7)); + } + bitPos++; + } + } + + // Mode indicator: byte mode = 0100 + writeBits(0b0100, 4); + // Character count indicator + const countBits = version.version >= 10 ? 16 : 8; + writeBits(textBytes.length, countBits); + // Data + for (const byte of textBytes) { + writeBits(byte, 8); + } + // Terminator + writeBits(0, Math.min(4, version.dataBytes * 8 - bitPos)); + + // Pad to byte boundary + bitPos = Math.ceil(bitPos / 8) * 8; + + // Pad bytes + const padBytes = [0xec, 0x11]; + let padIndex = 0; + while (bitPos < version.dataBytes * 8) { + writeBits(padBytes[padIndex % 2]!, 8); + padIndex++; + } + + return dataBytes; +} + +// ── Error correction and interleaving ──────────────────────────────── + +function computeCodewords(data: Uint8Array, version: VersionInfo): Uint8Array { + const blocks: Uint8Array[] = []; + const ecBlocks: Uint8Array[] = []; + let offset = 0; + + for (let g = 0; g < 2; g++) { + const blockCount = g === 0 ? version.group1Blocks : version.group2Blocks; + const blockDataBytes = g === 0 ? version.group1DataBytes : version.group2DataBytes; + for (let b = 0; b < blockCount; b++) { + const blockData = data.slice(offset, offset + blockDataBytes); + blocks.push(blockData); + ecBlocks.push(generateECBytes(blockData, version.ecBytesPerBlock)); + offset += blockDataBytes; + } + } + + // Interleave data blocks + const result: number[] = []; + const maxDataLen = Math.max(...blocks.map((b) => b.length)); + for (let i = 0; i < maxDataLen; i++) { + for (const block of blocks) { + if (i < block.length) result.push(block[i]!); + } + } + // Interleave EC blocks + for (let i = 0; i < version.ecBytesPerBlock; i++) { + for (const block of ecBlocks) { + if (i < block.length) result.push(block[i]!); + } + } + + return new Uint8Array(result); +} + +// ── Matrix construction ────────────────────────────────────────────── + +const ALIGNMENT_POSITIONS: Record = { + 2: [6, 18], + 3: [6, 22], + 4: [6, 26], + 5: [6, 30], + 6: [6, 34], + 7: [6, 22, 38], + 8: [6, 24, 42], + 9: [6, 26, 46], + 10: [6, 28, 52], +}; + +function createMatrix(version: VersionInfo, codewords: Uint8Array): boolean[][] { + const size = version.size; + const matrix: (boolean | null)[][] = Array.from({ length: size }, () => + Array.from({ length: size }, () => null), + ); + + function setModule(row: number, col: number, value: boolean) { + if (row >= 0 && row < size && col >= 0 && col < size) { + matrix[row]![col] = value; + } + } + + function isReserved(row: number, col: number): boolean { + return matrix[row]?.[col] !== null && matrix[row]?.[col] !== undefined; + } + + // Finder patterns + function placeFinderPattern(row: number, col: number) { + for (let r = -1; r <= 7; r++) { + for (let c = -1; c <= 7; c++) { + const isBlack = + (r >= 0 && r <= 6 && (c === 0 || c === 6)) || + (c >= 0 && c <= 6 && (r === 0 || r === 6)) || + (r >= 2 && r <= 4 && c >= 2 && c <= 4); + setModule(row + r, col + c, r >= 0 && r <= 6 && c >= 0 && c <= 6 ? isBlack : false); + } + } + } + + placeFinderPattern(0, 0); + placeFinderPattern(0, size - 7); + placeFinderPattern(size - 7, 0); + + // Alignment patterns + const positions = ALIGNMENT_POSITIONS[version.version]; + if (positions) { + for (const r of positions) { + for (const c of positions) { + // Skip if overlapping with finder patterns + if (r <= 8 && c <= 8) continue; + if (r <= 8 && c >= size - 8) continue; + if (r >= size - 8 && c <= 8) continue; + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const isBlack = Math.abs(dr) === 2 || Math.abs(dc) === 2 || (dr === 0 && dc === 0); + setModule(r + dr, c + dc, isBlack); + } + } + } + } + } + + // Timing patterns + for (let i = 8; i < size - 8; i++) { + setModule(6, i, i % 2 === 0); + setModule(i, 6, i % 2 === 0); + } + + // Dark module + setModule(size - 8, 8, true); + + // Reserve format info areas + for (let i = 0; i < 15; i++) { + // Around top-left finder + if (i < 6) setModule(8, i, false); + else if (i < 8) setModule(8, i + 1, false); + else if (i < 9) setModule(8 - (i - 8), 8, false); + else setModule(14 - i, 8, false); + + // Around other finders + if (i < 8) setModule(size - 1 - i, 8, false); + else setModule(8, size - 15 + i, false); + } + + // Place data bits + let bitIndex = 0; + const totalBits = codewords.length * 8; + let upward = true; + + for (let col = size - 1; col >= 1; col -= 2) { + if (col === 6) col = 5; // Skip timing column + + const rows = upward + ? Array.from({ length: size }, (_, i) => size - 1 - i) + : Array.from({ length: size }, (_, i) => i); + + for (const row of rows) { + for (let c = 0; c < 2; c++) { + const actualCol = col - c; + if (isReserved(row, actualCol)) continue; + if (bitIndex < totalBits) { + const bit = (codewords[bitIndex >> 3]! >> (7 - (bitIndex & 7))) & 1; + matrix[row]![actualCol] = bit === 1; + bitIndex++; + } else { + matrix[row]![actualCol] = false; + } + } + } + upward = !upward; + } + + // Apply mask pattern 0 (checkerboard) and write format info + const maskedMatrix: boolean[][] = matrix.map((row, r) => + row.map((cell, c) => { + const val = cell ?? false; + return (r + c) % 2 === 0 ? !val : val; + }), + ); + + // Write format info for mask 0, EC level L + // Pre-computed format string for L/mask0: 111011111000100 + const formatBits = 0b111011111000100; + for (let i = 0; i < 15; i++) { + const bit = ((formatBits >> (14 - i)) & 1) === 1; + // Around top-left + if (i < 6) maskedMatrix[8]![i] = bit; + else if (i < 8) maskedMatrix[8]![i + 1] = bit; + else if (i < 9) maskedMatrix[8 - (i - 8)]![8] = bit; + else maskedMatrix[14 - i]![8] = bit; + // Around bottom-left and top-right + if (i < 8) maskedMatrix[size - 1 - i]![8] = bit; + else maskedMatrix[8]![size - 15 + i] = bit; + } + + return maskedMatrix; +} + +// ── SVG rendering ──────────────────────────────────────────────────── + +function matrixToSvg(matrix: boolean[][], quietZone: number = 4): string { + const size = matrix.length + quietZone * 2; + const paths: string[] = []; + + for (let r = 0; r < matrix.length; r++) { + for (let c = 0; c < matrix[r]!.length; c++) { + if (matrix[r]![c]) { + paths.push(`M${c + quietZone},${r + quietZone}h1v1h-1z`); + } + } + } + + return [ + ``, + ``, + ``, + "", + ].join(""); +} + +// ── Public API ─────────────────────────────────────────────────────── + +/** + * Generate a QR code SVG string from the given text. + */ +export function generateQrSvg(text: string): string { + const textBytes = new TextEncoder().encode(text); + const version = selectVersion(textBytes.length); + const data = encodeData(text, version); + const codewords = computeCodewords(data, version); + const matrix = createMatrix(version, codewords); + return matrixToSvg(matrix); +} + +/** + * Generate a QR code as a data URL (SVG) from the given text. + */ +export function generateQrDataUrl(text: string): string { + const svg = generateQrSvg(text); + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} diff --git a/apps/web/src/lib/runtimeBridge.test.ts b/apps/web/src/lib/runtimeBridge.test.ts index 5bbf19c9d..54f57e62b 100644 --- a/apps/web/src/lib/runtimeBridge.test.ts +++ b/apps/web/src/lib/runtimeBridge.test.ts @@ -55,6 +55,10 @@ describe("runtimeBridge", () => { }), openExternal: async () => true, onPairingState: () => () => {}, + getConnectionState: () => "connected", + onConnectionState: () => () => {}, + registerNotifications: async () => true, + fireNotification: async () => {}, }; expect(resolveRuntimeWsUrl()).toBe("wss://pair.example/ws?token=secret"); @@ -85,6 +89,10 @@ describe("runtimeBridge", () => { }), openExternal: async () => true, onPairingState: () => () => {}, + getConnectionState: () => "disconnected", + onConnectionState: () => () => {}, + registerNotifications: async () => true, + fireNotification: async () => {}, }; expect(hasRuntimeConnectionTarget()).toBe(false); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 9395734bb..2cba25d85 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -25,6 +25,7 @@ import { providerQueryKeys } from "../lib/providerReactQuery"; import { projectQueryKeys } from "../lib/projectReactQuery"; import { collectActiveTerminalThreadIds } from "../lib/terminalStateCleanup"; import { OnboardingDialog } from "../components/onboarding/OnboardingDialog"; +import { MobileConnectionBanner } from "../components/mobile/MobileConnectionBanner"; import { MobilePairingScreen } from "../components/mobile/MobilePairingScreen"; import { useMobilePairingState } from "../hooks/useMobilePairingState"; import { I18nProvider } from "../i18n/I18nProvider"; @@ -79,6 +80,7 @@ function RootRouteContent() { return ( + diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 7cbb0c4c1..2a159e823 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -41,7 +41,7 @@ import { SidebarInset } from "../components/ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { CustomThemeDialog } from "../components/CustomThemeDialog"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; -import { isElectron } from "../env"; +import { isElectron, isMobileShell } from "../env"; import { useTheme, COLOR_THEMES, DEFAULT_COLOR_THEME, FONT_FAMILIES } from "../hooks/useTheme"; import { environmentVariablesQueryKeys, @@ -65,6 +65,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { cn } from "../lib/utils"; import { ensureNativeApi, readNativeApi } from "../nativeApi"; import { useStore } from "../store"; +import { PairingQrCode } from "../components/mobile/PairingQrCode"; const THEME_OPTIONS = [ { @@ -1471,6 +1472,19 @@ function SettingsRouteView() { + {!isMobileShell && ( + + +
+ +
+
+
+ )} + { for (const listener of transportStateListeners) { try { diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index f1b0be8a5..9108db2af 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -338,5 +338,15 @@ export class WsTransport { // Swallow listener errors } } + + // Emit a custom event so the mobile bridge can track connection state + // without a direct import dependency on the transport. + if (typeof window !== "undefined") { + try { + window.dispatchEvent(new CustomEvent("okcode:transport-state", { detail: nextState })); + } catch { + // Swallow dispatch errors in non-browser environments. + } + } } } diff --git a/bun.lock b/bun.lock index 328487d02..1ae751f4a 100644 --- a/bun.lock +++ b/bun.lock @@ -111,6 +111,7 @@ "@capacitor/browser": "^8.0.3", "@capacitor/core": "^8.3.0", "@capacitor/ios": "^8.3.0", + "@capacitor/local-notifications": "^8.0.1", "capacitor-secure-storage-plugin": "^0.13.0", }, "devDependencies": { @@ -341,6 +342,8 @@ "@capacitor/ios": ["@capacitor/ios@8.3.0", "", { "peerDependencies": { "@capacitor/core": "^8.3.0" } }, "sha512-5Rtwv8SITKlYTt8lAZG+khnVIdzPtqbocH3eP+JkEmX1vpSMwx4TOKtT8OBz8gpQ+pUJDRp7DBYOv3U6l/obCw=="], + "@capacitor/local-notifications": ["@capacitor/local-notifications@8.0.2", "", { "peerDependencies": { "@capacitor/core": ">=8.0.0" } }, "sha512-X7KE/I4ZutMTGVHNUyTjIugYcQEcHJRLks+TsPnOriuS+lo0geSTuaRln6zAZlJSSXSoVMSSzHexdSbIjR/8iw=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], "@codemirror/lang-angular": ["@codemirror/lang-angular@0.1.4", "", { "dependencies": { "@codemirror/lang-html": "^6.0.0", "@codemirror/lang-javascript": "^6.1.2", "@codemirror/language": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.3.3" } }, "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g=="], diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index dac4f7676..f0436fb10 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -258,6 +258,23 @@ export interface MobilePairingState { lastError: string | null; } +export type MobileConnectionState = "connected" | "connecting" | "reconnecting" | "disconnected"; + +export interface MobileNotificationEvent { + /** Unique ID for deduplication. */ + id: string; + /** Category determines the notification behavior and grouping. */ + category: "approval-requested" | "user-input-requested" | "turn-completed" | "session-error"; + /** Human-readable title. */ + title: string; + /** Human-readable body. */ + body: string; + /** Thread ID for deep-link navigation on tap. */ + threadId?: string; + /** Timestamp of the originating server event. */ + occurredAt: string; +} + export interface MobileBridge { getWsUrl: () => string | null; getPairingState: () => Promise; @@ -265,6 +282,16 @@ export interface MobileBridge { clearPairing: () => Promise; openExternal: (url: string) => Promise; onPairingState: (listener: (state: MobilePairingState) => void) => () => void; + + // ── Phase 3 additions ────────────────────────────────────────── + /** Current connection state of the WebSocket transport. */ + getConnectionState: () => MobileConnectionState; + /** Subscribe to connection state changes. Returns unsubscribe function. */ + onConnectionState: (listener: (state: MobileConnectionState) => void) => () => void; + /** Request permission and register for local push notifications. */ + registerNotifications: () => Promise; + /** Fire a local notification (used by the web layer when the app is backgrounded). */ + fireNotification: (event: MobileNotificationEvent) => Promise; } export interface NativeApi { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 42939b158..8502bd2de 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -76,3 +76,59 @@ export const ServerUpdateInfo = Schema.Struct({ updateAvailable: Schema.Boolean, }); export type ServerUpdateInfo = typeof ServerUpdateInfo.Type; + +// ── Token Management ──────────────────────────────────────────────── + +export const PairingTokenKind = Schema.Literals(["long-lived", "short-lived"]); +export type PairingTokenKind = typeof PairingTokenKind.Type; + +export const PairingTokenInfo = Schema.Struct({ + tokenId: TrimmedNonEmptyString, + kind: PairingTokenKind, + createdAt: IsoDateTime, + expiresAt: Schema.NullOr(IsoDateTime), + revoked: Schema.Boolean, + label: Schema.optional(TrimmedNonEmptyString), +}); +export type PairingTokenInfo = typeof PairingTokenInfo.Type; + +export const GeneratePairingLinkInput = Schema.Struct({ + /** + * Lifetime in seconds for the short-lived pairing token. + * Defaults to 300 (5 minutes) on the server when omitted. + */ + ttlSeconds: Schema.optional(Schema.Number), + label: Schema.optional(TrimmedNonEmptyString), +}); +export type GeneratePairingLinkInput = typeof GeneratePairingLinkInput.Type; + +export const GeneratePairingLinkResult = Schema.Struct({ + pairingUrl: TrimmedNonEmptyString, + token: TrimmedNonEmptyString, + expiresAt: IsoDateTime, +}); +export type GeneratePairingLinkResult = typeof GeneratePairingLinkResult.Type; + +export const RotateTokenResult = Schema.Struct({ + previousTokenId: Schema.NullOr(TrimmedNonEmptyString), + newToken: TrimmedNonEmptyString, + newTokenId: TrimmedNonEmptyString, + issuedAt: IsoDateTime, +}); +export type RotateTokenResult = typeof RotateTokenResult.Type; + +export const RevokeTokenInput = Schema.Struct({ + tokenId: TrimmedNonEmptyString, +}); +export type RevokeTokenInput = typeof RevokeTokenInput.Type; + +export const RevokeTokenResult = Schema.Struct({ + tokenId: TrimmedNonEmptyString, + revoked: Schema.Boolean, +}); +export type RevokeTokenResult = typeof RevokeTokenResult.Type; + +export const ListTokensResult = Schema.Struct({ + tokens: Schema.Array(PairingTokenInfo), +}); +export type ListTokensResult = typeof ListTokensResult.Type; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 122587965..aa28f2bbe 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -64,7 +64,12 @@ import { ProjectWriteFileInput, } from "./project"; import { OpenInEditorInput, OpenPathInput } from "./editor"; -import { ServerConfigUpdatedPayload } from "./server"; +import { + GeneratePairingLinkInput, + ListTokensResult, + RevokeTokenInput, + ServerConfigUpdatedPayload, +} from "./server"; import { SkillListInput, SkillCatalogInput, @@ -152,6 +157,12 @@ export const WS_METHODS = { serverSaveProjectEnvironmentVariables: "server.saveProjectEnvironmentVariables", serverUpsertKeybinding: "server.upsertKeybinding", serverPickFolder: "server.pickFolder", + + // Token management + serverGeneratePairingLink: "server.generatePairingLink", + serverRotateToken: "server.rotateToken", + serverRevokeToken: "server.revokeToken", + serverListTokens: "server.listTokens", } as const; // ── Push Event Channels ────────────────────────────────────────────── @@ -263,6 +274,12 @@ const WebSocketRequestBody = Schema.Union([ ), tagRequestBody(WS_METHODS.serverUpsertKeybinding, KeybindingRule), tagRequestBody(WS_METHODS.serverPickFolder, Schema.Struct({})), + + // Token management + tagRequestBody(WS_METHODS.serverGeneratePairingLink, GeneratePairingLinkInput), + tagRequestBody(WS_METHODS.serverRotateToken, Schema.Struct({})), + tagRequestBody(WS_METHODS.serverRevokeToken, RevokeTokenInput), + tagRequestBody(WS_METHODS.serverListTokens, Schema.Struct({})), ]); export const WebSocketRequest = Schema.Struct({