From 61a021f24e57ffcfa2904516156ea5a5d424c8b1 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 1 Apr 2026 09:18:41 -0500 Subject: [PATCH] Harden mobile pairing and notifications - Add QR pairing flow with clipboard paste and copy link support - Configure mobile notification permissions and background modes - Add tests for QR generation and mobile notification routing --- CHANGELOG.md | 16 + .../android/app/src/main/AndroidManifest.xml | 4 + apps/mobile/capacitor.config.ts | 7 + apps/mobile/ios/App/App/Info.plist | 5 + .../components/mobile/MobilePairingScreen.tsx | 54 +++- .../src/components/mobile/PairingQrCode.tsx | 48 +-- apps/web/src/lib/mobileNotifications.test.ts | 275 ++++++++++++++++++ apps/web/src/lib/qrCode.test.ts | 63 ++++ docs/releases/v0.0.13.md | 47 +++ 9 files changed, 497 insertions(+), 22 deletions(-) create mode 100644 apps/web/src/lib/mobileNotifications.test.ts create mode 100644 apps/web/src/lib/qrCode.test.ts create mode 100644 docs/releases/v0.0.13.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 11609b2f9..de882cd57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - CLI npm package name is `okcodes`. Install with `npm install -g okcodes`; the `okcode` binary name is unchanged. +## [0.0.13] - 2026-04-01 + +See [docs/releases/v0.0.13.md](docs/releases/v0.0.13.md) for full notes. + +### Added + +- Push notifications for approval requests, user-input requests, turn completions, and session errors on mobile. +- QR code pairing flow: desktop shows scannable QR, mobile supports clipboard paste and auto-pair. +- Token rotation and revocation model with short-lived pairing tokens. +- Connection state banner for mobile companion (connecting, reconnecting, disconnected). +- Android `POST_NOTIFICATIONS` and `SCHEDULE_EXACT_ALARM` permissions. +- iOS `UIBackgroundModes` for background processing. +- Capacitor `LocalNotifications` plugin configuration. +- `GET /api/pairing` HTTP endpoint for short-lived pairing link generation. +- WebSocket methods: `server.generatePairingLink`, `server.rotateToken`, `server.revokeToken`, `server.listTokens`. + ## [0.0.12] - 2026-04-01 See [docs/releases/v0.0.12.md](docs/releases/v0.0.12.md) for full notes and [docs/releases/v0.0.12/assets.md](docs/releases/v0.0.12/assets.md) for release asset inventory. diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 8685e51da..91dd4ef74 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -45,4 +45,8 @@ + + + + diff --git a/apps/mobile/capacitor.config.ts b/apps/mobile/capacitor.config.ts index 8585caffb..a2bca8946 100644 --- a/apps/mobile/capacitor.config.ts +++ b/apps/mobile/capacitor.config.ts @@ -7,6 +7,13 @@ const config: CapacitorConfig = { server: { androidScheme: "https", }, + plugins: { + LocalNotifications: { + smallIcon: "ic_launcher", + iconColor: "#10B981", + sound: "default", + }, + }, }; export default config; diff --git a/apps/mobile/ios/App/App/Info.plist b/apps/mobile/ios/App/App/Info.plist index 455cef0d9..9bc48ca1c 100644 --- a/apps/mobile/ios/App/App/Info.plist +++ b/apps/mobile/ios/App/App/Info.plist @@ -60,5 +60,10 @@ UIViewControllerBasedStatusBarAppearance + UIBackgroundModes + + fetch + processing + diff --git a/apps/web/src/components/mobile/MobilePairingScreen.tsx b/apps/web/src/components/mobile/MobilePairingScreen.tsx index f604ff606..a9522f9b2 100644 --- a/apps/web/src/components/mobile/MobilePairingScreen.tsx +++ b/apps/web/src/components/mobile/MobilePairingScreen.tsx @@ -34,6 +34,40 @@ export function MobilePairingScreen() { } }; + const handlePasteFromClipboard = async () => { + try { + const text = await navigator.clipboard.readText(); + if (!text || text.trim().length === 0) { + setErrorMessage("Clipboard is empty."); + return; + } + setPairingInput(text.trim()); + setErrorMessage(null); + + // Auto-submit if it looks like a valid pairing link. + if ( + mobileBridge && + (text.trim().startsWith("okcode://") || text.trim().includes("?token=")) + ) { + setIsSubmitting(true); + try { + const nextState = await mobileBridge.applyPairingUrl(text.trim()); + if (!nextState.paired) { + setErrorMessage(nextState.lastError ?? "Could not pair this device."); + return; + } + window.location.reload(); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : "Could not pair this device."); + } finally { + setIsSubmitting(false); + } + } + } catch { + setErrorMessage("Could not read clipboard. Paste the link manually instead."); + } + }; + const handleReset = async () => { if (!mobileBridge) { return; @@ -63,8 +97,8 @@ export function MobilePairingScreen() {

Pair this device

- Paste a pairing link like okcode://pair?server=…&token=… or a server URL - that includes ?token=…. + Open Settings → Mobile Companion on your desktop to show a QR + pairing code, then copy the link and paste it below.

@@ -81,13 +115,25 @@ export function MobilePairingScreen() {
- +
+ +

+ You can also open a pairing link directly from another app — it will be handled + automatically via deep link. +

); diff --git a/apps/web/src/components/mobile/PairingQrCode.tsx b/apps/web/src/components/mobile/PairingQrCode.tsx index 54ee49ad3..935060dde 100644 --- a/apps/web/src/components/mobile/PairingQrCode.tsx +++ b/apps/web/src/components/mobile/PairingQrCode.tsx @@ -23,10 +23,12 @@ export function PairingQrCode() { const [loading, setLoading] = useState(false); const [svgHtml, setSvgHtml] = useState(null); const [expiresIn, setExpiresIn] = useState(null); + const [copied, setCopied] = useState(false); const fetchPairingLink = useCallback(async () => { setLoading(true); setError(null); + setCopied(false); try { const origin = resolveServerHttpOrigin(); const response = await fetch(`${origin}/api/pairing?ttl=300`); @@ -80,6 +82,17 @@ export function PairingQrCode() { return () => clearInterval(interval); }, [pairing?.expiresAt, fetchPairingLink]); + const handleCopyLink = async () => { + if (!pairing?.pairingUrl) return; + try { + await navigator.clipboard.writeText(pairing.pairingUrl); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: select the text in the details element + } + }; + const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60); const s = seconds % 60; @@ -116,24 +129,23 @@ export function PairingQrCode() { {expiresIn > 0 ? <>Expires in {formatTime(expiresIn)} : <>Refreshing...}

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

+ Scan the QR code with your phone camera, or copy the link and paste it in the mobile + app. +

) : loading ? (
diff --git a/apps/web/src/lib/mobileNotifications.test.ts b/apps/web/src/lib/mobileNotifications.test.ts new file mode 100644 index 000000000..e6533c76a --- /dev/null +++ b/apps/web/src/lib/mobileNotifications.test.ts @@ -0,0 +1,275 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock runtimeBridge before the module under test is imported. +vi.mock("./runtimeBridge", () => ({ + readMobileBridge: vi.fn(), +})); + +import type { MobileBridge, MobileNotificationEvent } from "@okcode/contracts"; +import { readMobileBridge } from "./runtimeBridge"; +import { initMobileNotifications } from "./mobileNotifications"; + +const mockReadMobileBridge = vi.mocked(readMobileBridge); + +function createMockBridge(): MobileBridge & { + _firedNotifications: MobileNotificationEvent[]; +} { + const fired: MobileNotificationEvent[] = []; + return { + getWsUrl: () => null, + getPairingState: async () => ({ + paired: false, + serverUrl: null, + tokenPresent: false, + lastError: null, + }), + applyPairingUrl: async () => ({ + paired: false, + serverUrl: null, + tokenPresent: false, + lastError: null, + }), + clearPairing: async () => ({ + paired: false, + serverUrl: null, + tokenPresent: false, + lastError: null, + }), + openExternal: async () => true, + onPairingState: () => () => {}, + getConnectionState: () => "connected", + onConnectionState: () => () => {}, + registerNotifications: vi.fn(async () => true), + fireNotification: vi.fn(async (event: MobileNotificationEvent) => { + fired.push(event); + }), + _firedNotifications: fired, + }; +} + +type PushListener = (push: { data: unknown }) => void; + +function createMockTransport() { + const listeners = new Map>(); + return { + subscribe: vi.fn((channel: string, listener: PushListener) => { + let set = listeners.get(channel); + if (!set) { + set = new Set(); + listeners.set(channel, set); + } + set.add(listener); + return () => { + set?.delete(listener); + }; + }), + _emit(channel: string, data: unknown) { + for (const listener of listeners.get(channel) ?? []) { + listener({ data }); + } + }, + }; +} + +// Stub `document` for the test environment (Node.js has no DOM by default). +let mockVisibilityState = "visible"; + +beforeEach(() => { + mockVisibilityState = "visible"; + if (typeof globalThis.document === "undefined") { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: { + get visibilityState() { + return mockVisibilityState; + }, + }, + }); + } +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function setBackgrounded(hidden: boolean) { + mockVisibilityState = hidden ? "hidden" : "visible"; +} + +describe("mobileNotifications", () => { + it("does not fire notifications when the app is in the foreground", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(false); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.approval-response-requested", + eventId: "evt-1", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t1" }, + }); + + expect(bridge.fireNotification).not.toHaveBeenCalled(); + cleanup(); + }); + + it("fires a notification for approval-requested when backgrounded", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.approval-response-requested", + eventId: "evt-2", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t2" }, + }); + + expect(bridge.fireNotification).toHaveBeenCalledOnce(); + expect(bridge._firedNotifications[0]).toMatchObject({ + category: "approval-requested", + title: "Approval Requested", + threadId: "t2", + }); + cleanup(); + }); + + it("fires a notification for user-input-requested when backgrounded", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.user-input-response-requested", + eventId: "evt-3", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t3" }, + }); + + expect(bridge._firedNotifications[0]).toMatchObject({ + category: "user-input-requested", + title: "Input Needed", + }); + cleanup(); + }); + + it("fires a notification for turn-completed when backgrounded", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.turn-diff-completed", + eventId: "evt-4", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t4" }, + }); + + expect(bridge._firedNotifications[0]).toMatchObject({ + category: "turn-completed", + title: "Turn Completed", + }); + cleanup(); + }); + + it("fires a session-error notification for error status", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.session-set", + eventId: "evt-5", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t5", status: { status: "error" } }, + }); + + expect(bridge._firedNotifications[0]).toMatchObject({ + category: "session-error", + title: "Session Error", + }); + cleanup(); + }); + + it("ignores session-set events that are not errors", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "thread.session-set", + eventId: "evt-6", + occurredAt: "2026-04-01T00:00:00Z", + payload: { threadId: "t6", status: { status: "running" } }, + }); + + expect(bridge.fireNotification).not.toHaveBeenCalled(); + cleanup(); + }); + + it("ignores unrelated orchestration events", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + setBackgrounded(true); + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + transport._emit("orchestration.domainEvent", { + type: "project.created", + eventId: "evt-7", + occurredAt: "2026-04-01T00:00:00Z", + }); + + expect(bridge.fireNotification).not.toHaveBeenCalled(); + cleanup(); + }); + + it("returns a no-op when no mobile bridge is available", () => { + mockReadMobileBridge.mockReturnValue(undefined); + const transport = createMockTransport(); + + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + expect(typeof cleanup).toBe("function"); + expect(transport.subscribe).not.toHaveBeenCalled(); + cleanup(); + }); + + it("eagerly registers notification permissions", () => { + const bridge = createMockBridge(); + mockReadMobileBridge.mockReturnValue(bridge); + const transport = createMockTransport(); + + // biome-ignore lint/suspicious/noExplicitAny: test mock + const cleanup = initMobileNotifications(transport as any); + + expect(bridge.registerNotifications).toHaveBeenCalledOnce(); + cleanup(); + }); +}); diff --git a/apps/web/src/lib/qrCode.test.ts b/apps/web/src/lib/qrCode.test.ts new file mode 100644 index 000000000..d6c1d6144 --- /dev/null +++ b/apps/web/src/lib/qrCode.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; + +import { generateQrDataUrl, generateQrSvg } from "./qrCode"; + +describe("qrCode", () => { + describe("generateQrSvg", () => { + it("returns a valid SVG string for short text", () => { + const svg = generateQrSvg("hello"); + expect(svg).toContain(""); + expect(svg).toContain('xmlns="http://www.w3.org/2000/svg"'); + }); + + it("includes a white background rect and black data path", () => { + const svg = generateQrSvg("test"); + expect(svg).toContain('fill="#fff"'); + expect(svg).toContain('fill="#000"'); + }); + + it("generates different SVGs for different inputs", () => { + const svg1 = generateQrSvg("aaa"); + const svg2 = generateQrSvg("bbb"); + expect(svg1).not.toBe(svg2); + }); + + it("handles a typical pairing URL without throwing", () => { + const url = "okcode://pair?server=http%3A%2F%2F192.168.1.42%3A3773&token=abc123def456"; + const svg = generateQrSvg(url); + expect(svg).toContain(""); + }); + + it("handles URLs up to ~200 characters (version 7+)", () => { + const longUrl = `okcode://pair?server=http%3A%2F%2Fmy-tailnet-host.example.com%3A3773&token=${"a".repeat(120)}`; + const svg = generateQrSvg(longUrl); + expect(svg).toContain(" { + const huge = "x".repeat(500); + expect(() => generateQrSvg(huge)).toThrow("Data too long"); + }); + + it("uses crispEdges rendering for clean pixel grid", () => { + const svg = generateQrSvg("qr"); + expect(svg).toContain('shape-rendering="crispEdges"'); + }); + }); + + describe("generateQrDataUrl", () => { + it("returns a data: URL with SVG MIME type", () => { + const dataUrl = generateQrDataUrl("test"); + expect(dataUrl).toMatch(/^data:image\/svg\+xml;charset=utf-8,/); + }); + + it("contains the encoded SVG content", () => { + const dataUrl = generateQrDataUrl("test"); + const decoded = decodeURIComponent(dataUrl.replace("data:image/svg+xml;charset=utf-8,", "")); + expect(decoded).toContain(""); + }); + }); +}); diff --git a/docs/releases/v0.0.13.md b/docs/releases/v0.0.13.md new file mode 100644 index 000000000..9df85520b --- /dev/null +++ b/docs/releases/v0.0.13.md @@ -0,0 +1,47 @@ +# OK Code v0.0.13 + +**Date:** 2026-04-01 +**Tag:** [`v0.0.13`](https://github.com/OpenKnots/okcode/releases/tag/v0.0.13) + +## Summary + +Release 0.0.13 completes **Phase 3: Notifications & Pairing Hardening** for the mobile companion app, making the v1 mobile rollout production-ready. Users can now receive push notifications, pair via QR code, and benefit from token rotation and improved connection reliability. + +## Highlights + +- **Push notifications.** The mobile companion now fires local notifications when the app is backgrounded and an attention-requiring event arrives: approval requests, user-input requests, turn completions, and session errors. Notification channels are configured for both Android (importance levels) and iOS. + +- **QR code pairing.** The desktop Settings page includes a new "Mobile Companion" section with a scannable QR code. The code auto-refreshes every 5 minutes and uses short-lived tokens so expired codes cannot be reused. A "Copy pairing link" button is available for clipboard-based pairing. + +- **Token rotation and revocation.** The server now supports token lifecycle management: generate short-lived pairing tokens, rotate the primary auth token (with a 30-second grace period for in-flight connections), revoke individual tokens, and list all issued tokens. Four new WebSocket methods expose this functionality. + +- **Improved mobile pairing UX.** The mobile pairing screen now offers a "Paste from clipboard" button that auto-detects and applies valid pairing links. Instructions guide users to the desktop QR code flow. + +- **Connection state banner.** When running in the mobile shell, a context-aware banner displays connection status: blue for connecting, amber with pulse animation for reconnecting, and red for disconnected. The banner is hidden when connected. + +- **Native notification permissions.** Android `POST_NOTIFICATIONS` and `SCHEDULE_EXACT_ALARM` permissions and iOS `UIBackgroundModes` are now declared in the native project manifests. Capacitor plugin configuration includes notification icon and color defaults. + +## Breaking changes + +- None. + +## New WebSocket methods + +- `server.generatePairingLink` — Generate a short-lived pairing URL with configurable TTL. +- `server.rotateToken` — Issue a new primary auth token and grace-period revoke the old one. +- `server.revokeToken` — Immediately invalidate a specific token by ID. +- `server.listTokens` — List all issued tokens (values masked). + +## New HTTP endpoint + +- `GET /api/pairing?ttl=` — Returns a JSON object with `pairingUrl`, `expiresAt`, and `serverUrl` for QR code generation. Requires auth to be enabled on the server. + +## Upgrade and install + +- **CLI:** `npm install -g okcodes@0.0.13` (after the package is published to npm manually). +- **Desktop:** Download from [GitHub Releases](https://github.com/OpenKnots/okcode/releases/tag/v0.0.13). Filenames are listed in [assets.md](v0.0.13/assets.md). + +## Known limitations + +- **QR scanning on mobile** is not yet supported natively. Users must copy the pairing link from the desktop QR screen and paste it in the mobile app, or open the link via deep link from another app. +- OK Code remains early work in progress. Expect rough edges around session recovery, streaming edge cases, and platform-specific desktop behavior. Report issues on GitHub.