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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
},
"plugins": [
"expo-router",
"expo-secure-store"
"expo-secure-store",
"expo-notifications"
],
"experiments": {
"typedRoutes": true
Expand Down
46 changes: 45 additions & 1 deletion app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Stack screenOptions={{ headerShown: false }} />
Expand Down
12 changes: 11 additions & 1 deletion app/sessions.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Redirect, router } from "expo-router";
import { useState } from "react";
import { useEffect, useState } from "react";
import {
FlatList,
Pressable,
Expand All @@ -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
Expand Down Expand Up @@ -43,7 +44,16 @@ function SessionList({ conn }: { conn: NonNullable<ReturnType<typeof getConnecti
status.auth.protocolVersion !== undefined &&
status.auth.protocolVersion !== PROTOCOL_VERSION;

// A push tapped before the app was connected (cold launch) stashed its
// session id; now that the fleet view is up (connected), open that session.
useEffect(() => {
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("/");
Expand Down
21 changes: 21 additions & 0 deletions eas.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
}
103 changes: 88 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion src/lib/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
});
Expand All @@ -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;
Expand Down
44 changes: 44 additions & 0 deletions src/lib/push-protocol.ts
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading