From e7afe11792effdb12f8493d43f3f801e983fbb98 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 26 Jul 2026 13:46:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20P2=20push=20client=20=E2=80=94=20regist?= =?UTF-8?q?er=20device=20+=20route=20approval=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 2 of push: codeoid-mobile now registers for and routes the daemon's content-blind approval pushes. Pairs with the daemon backbone (codeoid #249, merged); consumes push.* via a local wire shim until that ships in a published @codeoid/protocol release. - Declares the `push` capability; on a connect where the daemon advertises it (i.e. a push transport is configured), requests notification permission, fetches this device's Expo push token, and sends push.register { token, platform }. Fire-and-forget — a registration failure never fails the connection. Unregisters on sign-out. - Tapping a push opens its session (the P2 approval bar shows what's pending): warm taps navigate immediately; a cold-launch tap is stashed and opened once the sessions screen is connected. Foreground pushes still show a banner (approvals are time-sensitive). - src/lib/push-protocol.ts: local mirror of push.register / push.unregister + the "push" capability, cast at one send boundary. Delete and import from @codeoid/protocol once the release ships — the shapes are identical by construction and the daemon already validates them. - Deps (Expo SDK 57 bundled): expo-notifications@~57.0.3, expo-device@~57.0.0. app.json gains the expo-notifications plugin; new eas.json (development / preview / production profiles). Note: remote push tokens require a dev/standalone build — getExpoPushToken throws in Expo Go for a custom bundle id — so push lights up under `eas build --profile development`, not Expo Go. The client degrades gracefully (logs + skips) everywhere else, and no-ops on web. Verified: tsc + eslint clean; expo export (web) bundles 1018 modules (expo-notifications / expo-device import cleanly). Device e2e (real push delivery + tap) needs the dev build plus a daemon with push configured. Signed-off-by: Yash Datta Co-Authored-By: Claude Opus 4.8 (1M context) --- app.json | 3 +- app/_layout.tsx | 46 +++++++++++++++- app/sessions.tsx | 12 ++++- eas.json | 21 ++++++++ package-lock.json | 103 +++++++++++++++++++++++++++++------ package.json | 2 + src/lib/connection.ts | 10 +++- src/lib/push-protocol.ts | 44 +++++++++++++++ src/lib/push.ts | 112 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 334 insertions(+), 19 deletions(-) create mode 100644 eas.json create mode 100644 src/lib/push-protocol.ts create mode 100644 src/lib/push.ts diff --git a/app.json b/app.json index abded09..6f7d9f0 100644 --- a/app.json +++ b/app.json @@ -21,7 +21,8 @@ }, "plugins": [ "expo-router", - "expo-secure-store" + "expo-secure-store", + "expo-notifications" ], "experiments": { "typedRoutes": true diff --git a/app/_layout.tsx b/app/_layout.tsx index a9aa203..7c237ca 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,51 @@ -import { Stack } from "expo-router"; +import { Stack, useRouter } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import * as Notifications from "expo-notifications"; +import { useEffect } from "react"; + +import { getConnection } from "@/lib/connection"; +import { sessionIdFromNotification, setPendingSession } from "@/lib/push"; + +// Approvals are time-sensitive — show the banner even while the app is +// foregrounded (the in-app approval bar handles the actual decision). +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowBanner: true, + shouldShowList: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); export default function RootLayout() { + const router = useRouter(); + + useEffect(() => { + // Route a tapped push to its session. If we're already connected, open it + // now; otherwise (cold launch / mid-connect) stash it — the sessions screen + // opens it once the connection is up. + const openSession = (sessionId: string) => { + if (getConnection()?.client.status.kind === "connected") { + router.navigate({ pathname: "/session/[id]", params: { id: sessionId } }); + } else { + setPendingSession(sessionId); + } + }; + + const sub = Notifications.addNotificationResponseReceivedListener((response) => { + const sessionId = sessionIdFromNotification(response); + if (sessionId) openSession(sessionId); + }); + + // Cold launch straight from a notification: stash for the sessions screen. + void Notifications.getLastNotificationResponseAsync().then((response) => { + const sessionId = sessionIdFromNotification(response); + if (sessionId) setPendingSession(sessionId); + }); + + return () => sub.remove(); + }, [router]); + return ( <> diff --git a/app/sessions.tsx b/app/sessions.tsx index a53a404..6719c8b 100644 --- a/app/sessions.tsx +++ b/app/sessions.tsx @@ -1,5 +1,5 @@ import { Redirect, router } from "expo-router"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { FlatList, Pressable, @@ -15,6 +15,7 @@ import { PROTOCOL_VERSION, type SessionInfo } from "@codeoid/protocol"; import { clearCredentials } from "@/lib/auth"; import { closeConnection, getConnection } from "@/lib/connection"; import { useConnectionStatus, useSessions } from "@/lib/hooks"; +import { consumePendingSession, unregisterForPush } from "@/lib/push"; import { palette, statusColor } from "@/lib/theme"; // Fleet view (design doc §8): the session list. Attach-on-select opens the @@ -43,7 +44,16 @@ function SessionList({ conn }: { conn: NonNullable { + if (status.kind !== "connected") return; + const pending = consumePendingSession(); + if (pending) router.navigate({ pathname: "/session/[id]", params: { id: pending } }); + }, [status.kind]); + const signOut = async () => { + await unregisterForPush(getConnection()); closeConnection(); await clearCredentials(); router.replace("/"); diff --git a/eas.json b/eas.json new file mode 100644 index 0000000..5cc8d38 --- /dev/null +++ b/eas.json @@ -0,0 +1,21 @@ +{ + "cli": { + "version": ">= 5.9.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal" + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/package-lock.json b/package-lock.json index 2d07972..6083d82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,9 @@ "diff": "^9.0.0", "expo": "~57.0.4", "expo-constants": "~57.0.3", + "expo-device": "~57.0.0", "expo-linking": "~57.0.2", + "expo-notifications": "~57.0.3", "expo-router": "~57.0.4", "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", @@ -1571,9 +1573,9 @@ } }, "node_modules/@expo/env": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.1.tgz", - "integrity": "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.2.tgz", + "integrity": "sha512-28pqaEqwnmLduZ00Pq9HkSzE5wbj1MTwp5/n8nm8rD8MCjR9eUnVOwmNksPI3Be2ReAPO/DbPn1puy0mvoocsQ==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", @@ -1613,12 +1615,12 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.1.tgz", - "integrity": "sha512-0JueH4vdgAZQmhlSYFvTQzt4b4NO5cnByDuApw7bMUIjhwLRnT46Ki3ritMrzJMQaO2lLK2flInZbsZbOuy8nw==", + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.4.tgz", + "integrity": "sha512-pn/4770DIEOcYZr484uazuwg20FX/qaDkeMRF6J+oxejynDmEmO8wLsCudaNShFE0BhyKGQTYrs2rsRhqrqESw==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^57.0.1", + "@expo/require-utils": "^57.0.4", "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", @@ -1829,9 +1831,9 @@ } }, "node_modules/@expo/require-utils": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.1.tgz", - "integrity": "sha512-uXen5/4x7j60I5slShgZr5QEtJDBK8homFiNLDnDrNrxZhrRHXASo0H6JArs3/1PDzw1wahzhGWg2WKuYyZd0A==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.4.tgz", + "integrity": "sha512-e7xbg/9BTQcsZE/oErafZXtI7kh5IgfasLJ97J5sFSzX2cA74pDvdlhW1KHVSaDkQyQv6h1LSLhsY7dEeOk7hw==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -1839,7 +1841,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -4246,6 +4248,12 @@ } } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -6079,6 +6087,15 @@ } } }, + "node_modules/expo-application": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.2.tgz", + "integrity": "sha512-q31YwcXyymviAmdrtDfAg3Dld4VMxLCNAfgMHip7vZpPX4lzF/AfwsywqBJUAjQnJknzaNAkzqvMVjO5XmKYDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "57.0.3", "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.3.tgz", @@ -6095,18 +6112,56 @@ } }, "node_modules/expo-constants": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.3.tgz", - "integrity": "sha512-BghbZlzwFnA22BG0CWv6W29zx8w19FozYPfSeZ3HjMitoy4aAmF1FnFbbjYSmZz6HHXXTWOfqwobOEIaglfzxg==", + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.7.tgz", + "integrity": "sha512-ShDwaKnh3UieCQ/dG0kO8PuicTTatn3WDGmXbq/fukyzPXWhdUxf37DINVDQS6D3DDG7nfqItij+QvnDHSQhTg==", "license": "MIT", "dependencies": { - "@expo/env": "~2.4.1" + "@expo/env": "~2.4.2" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, + "node_modules/expo-device": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-57.0.1.tgz", + "integrity": "sha512-jyEMDUticH+dhcL3GHa2aiifOvGXJsmb3oVT2R2q4i8bN7Bddy61+NkpMmuS2VAZrvoLQwf0TJJ/1vi1ukvutA==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz", + "integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.0.tgz", @@ -6211,6 +6266,24 @@ "react-native": "*" } }, + "node_modules/expo-notifications": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-57.0.7.tgz", + "integrity": "sha512-77cqQ1E3B8RQ7FadKSl+bOeSzUfbMhbjMxklqQffXIHx1dILrRELFhq5/UkBW66r+F58KtGEMlw0atPVY2qJyQ==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.4", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~57.0.2", + "expo-constants": "~57.0.7" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "57.0.4", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.4.tgz", diff --git a/package.json b/package.json index 6398575..c2e38b2 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,9 @@ "diff": "^9.0.0", "expo": "~57.0.4", "expo-constants": "~57.0.3", + "expo-device": "~57.0.0", "expo-linking": "~57.0.2", + "expo-notifications": "~57.0.3", "expo-router": "~57.0.4", "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", diff --git a/src/lib/connection.ts b/src/lib/connection.ts index 5dfc8db..1016edd 100644 --- a/src/lib/connection.ts +++ b/src/lib/connection.ts @@ -19,6 +19,8 @@ import { CodeoidClient, MessageStore, ResumeCursors } from "@codeoid/core"; import { CAPABILITIES } from "@codeoid/protocol"; import { exchangeApiKey } from "./auth"; +import { registerForPush } from "./push"; +import { PUSH_CAPABILITY } from "./push-protocol"; export const CLIENT_NAME = "codeoid-mobile/0.0.1"; @@ -78,6 +80,9 @@ export async function openConnection(opts: { CAPABILITIES.CHUNKED_REPLAY, CAPABILITIES.SEQ_RESUME, CAPABILITIES.SEND_IDEMPOTENCY, + // Push notifications. Literal (not CAPABILITIES.PUSH) until the daemon's + // push backbone ships in a published @codeoid/protocol release. + PUSH_CAPABILITY, ], clientName: CLIENT_NAME, }); @@ -102,11 +107,14 @@ export async function openConnection(opts: { current = { daemonUrl: opts.daemonUrl, client, store, cursors }; try { - await withTimeout( + const authOk = await withTimeout( client.connect(), opts.connectTimeoutMs ?? 30_000, "daemon WebSocket connect timed out", ); + // Register this device for push once we know the daemon supports it. + // Fire-and-forget — a push-registration failure must never fail connect. + void registerForPush(current, authOk.capabilities); } catch (err) { closeConnection(); throw err; diff --git a/src/lib/push-protocol.ts b/src/lib/push-protocol.ts new file mode 100644 index 0000000..710e6c0 --- /dev/null +++ b/src/lib/push-protocol.ts @@ -0,0 +1,44 @@ +/** + * Local mirror of the daemon's push wire messages (`@codeoid/protocol`'s + * `push.register` / `push.unregister` + the `"push"` capability). + * + * The daemon's push backbone is merged but NOT yet in a published + * `@codeoid/protocol` release, and this app pins the registry version — so the + * installed protocol predates `push.*`. These shims let the client speak the + * (already-live, daemon-validated) push contract now. When mobile bumps to the + * release that ships `push.*`, delete this file and import from + * `@codeoid/protocol` instead — the shapes are identical by construction. + */ +import type { CodeoidClient } from "@codeoid/core"; +import type { ClientMessage } from "@codeoid/protocol"; + +/** Capability the client declares; the daemon advertises it back on `auth.ok` + * only when a push transport is configured. Matches `CAPABILITIES.PUSH`. */ +export const PUSH_CAPABILITY = "push"; + +export type PushPlatform = "ios" | "android"; + +export interface PushRegisterMsg { + type: "push.register"; + id: string; + token: string; + platform: PushPlatform; +} + +export interface PushUnregisterMsg { + type: "push.unregister"; + id: string; + token: string; +} + +/** + * Send a `push.*` frame. Cast at this single boundary: the installed + * `@codeoid/protocol` predates `push.*` in its `ClientMessage` union, but the + * daemon validates the shape server-side, so the wire contract holds. + */ +export function sendPushMsg( + client: CodeoidClient, + msg: PushRegisterMsg | PushUnregisterMsg, +): void { + client.send(msg as unknown as ClientMessage); +} diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..362688e --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,112 @@ +/** + * Mobile push registration + notification routing (design doc §7). + * + * On a successful connect where the daemon advertises the `push` capability, + * request notification permission, fetch this device's Expo push token, and + * register it with the daemon. The daemon then sends a CONTENT-BLIND wake-up + * (an opaque session id only) when one of this user's sessions blocks on + * approval; tapping it deep-links into that session, where the P2 approval bar + * shows what's pending. + * + * Expo Go caveat: remote push tokens require a dev/standalone build with the + * project's EAS credentials — `getExpoPushTokenAsync` throws in Expo Go for a + * custom bundle id. We degrade gracefully (log + skip) so the rest of the app + * works unchanged; push lights up in a dev build. + */ +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { Platform } from "react-native"; + +import type { Connection } from "./connection"; +import { PUSH_CAPABILITY, sendPushMsg, type PushPlatform } from "./push-protocol"; + +/** Token registered this session, so sign-out can unregister exactly it. */ +let registeredToken: string | null = null; + +/** A session id from a notification tapped before a connection existed (cold + * launch) — the sessions screen consumes it once connected. */ +let pendingSessionId: string | null = null; + +function nativePlatform(): PushPlatform | null { + if (Platform.OS === "ios") return "ios"; + if (Platform.OS === "android") return "android"; + return null; // web / unsupported — no mobile push +} + +function easProjectId(): string | undefined { + return ( + Constants.expoConfig?.extra?.eas?.projectId ?? + (Constants as { easConfig?: { projectId?: string } }).easConfig?.projectId + ); +} + +/** + * Register this device for push if the daemon supports it. Fire-and-forget: + * any failure (permission denied, Expo Go, no EAS project) logs and returns + * without disturbing the connection. + */ +export async function registerForPush( + conn: Connection, + daemonCapabilities: readonly string[] | undefined, +): Promise { + const platform = nativePlatform(); + if (!platform) return; + // Only register when the daemon will actually deliver — avoids a needless + // permission prompt against a daemon with no push transport configured. + if (!daemonCapabilities?.includes(PUSH_CAPABILITY)) return; + if (!Device.isDevice) return; // simulators/emulators can't get a push token + + try { + let perm = await Notifications.getPermissionsAsync(); + if (!perm.granted && perm.canAskAgain) { + perm = await Notifications.requestPermissionsAsync(); + } + if (!perm.granted) return; + + const projectId = easProjectId(); + const { data: token } = await Notifications.getExpoPushTokenAsync( + projectId ? { projectId } : undefined, + ); + if (conn.client.status.kind !== "connected") return; + sendPushMsg(conn.client, { type: "push.register", id: conn.client.nextId(), token, platform }); + registeredToken = token; + } catch (err) { + console.warn("[push] registration skipped:", err instanceof Error ? err.message : err); + } +} + +/** Unregister this device (sign-out). Best-effort; sent before the socket closes. */ +export async function unregisterForPush(conn: Connection | null): Promise { + const token = registeredToken; + registeredToken = null; + if (!conn || !token) return; + try { + if (conn.client.status.kind === "connected") { + sendPushMsg(conn.client, { type: "push.unregister", id: conn.client.nextId(), token }); + } + } catch { + // Socket may already be gone — the daemon prunes tokens that fail delivery. + } +} + +/** The session id a notification carries (opaque routing id), or null. */ +export function sessionIdFromNotification( + response: Notifications.NotificationResponse | null | undefined, +): string | null { + const data = response?.notification.request.content.data; + const sessionId = + data && typeof data === "object" ? (data as Record).sessionId : undefined; + return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : null; +} + +export function setPendingSession(sessionId: string): void { + pendingSessionId = sessionId; +} + +/** Consume a pending cold-launch deep-link target (single-shot). */ +export function consumePendingSession(): string | null { + const id = pendingSessionId; + pendingSessionId = null; + return id; +}