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;
+}