diff --git a/README.md b/README.md index b6be9c3..5840636 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Native **iOS + Android** (and web, via React Native Web) client for the [Codeoid](https://github.com/saucam/codeoid) daemon — a self-hosted, identity-native control plane for AI coding agents. -> **Status: early scaffold / design phase — not yet functional.** This repo currently holds the design and project skeleton; the app is built in phases per [`docs/mobile-app-design.md`](docs/mobile-app-design.md). +> **Status: P1 — connect, auth, attach.** Daemon-URL entry → ZeroID API-key sign-in → live session list → streaming transcript (plain-text rows). Built in phases per [`docs/mobile-app-design.md`](docs/mobile-app-design.md) §10; next up: rich transcript + approvals + push (P2). ## What it is @@ -23,14 +23,20 @@ What sets it apart from other "control your coding agent from your phone" apps: ## Getting started -This is a scaffold — dependencies are pinned to the SDK 57 family but not yet installed/validated. When building begins: - ```bash npm install -npx expo install --fix # reconcile RN / react / expo-* versions to the SDK npx expo start # then press i / a / w ``` +On the connect screen, enter your daemon URL (e.g. `http://192.168.1.x:7400`) and a +ZeroID API key (`zid_sk_…`). The key is stored in the device Keychain/Keystore and +exchanged for a short-lived JWT via the daemon's same-origin `/oauth2/token` proxy; +the JWT is re-minted on every reconnect. Google OAuth sign-in lands in P3. + +Note: `metro.config.js` carries a resolver fallback because `@codeoid/protocol` / +`@codeoid/core` ship raw TypeScript source with TS-ESM style `.js` relative imports, +which Metro does not redirect to `.ts` inside `node_modules` on its own. + ## Related repos - [`saucam/codeoid`](https://github.com/saucam/codeoid) — the daemon + CLI + Solid web UI (the source of `@codeoid/protocol` / `@codeoid/core`) diff --git a/app.json b/app.json index 8b9fcc5..abded09 100644 --- a/app.json +++ b/app.json @@ -19,7 +19,10 @@ "bundler": "metro", "output": "static" }, - "plugins": ["expo-router"], + "plugins": [ + "expo-router", + "expo-secure-store" + ], "experiments": { "typedRoutes": true } diff --git a/app/index.tsx b/app/index.tsx index 3da4f5c..1c18c5a 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -1,28 +1,117 @@ -import { useState } from "react"; -import { StyleSheet, Text, TextInput, View } from "react-native"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, +} from "react-native"; -// Placeholder connect screen. See docs/mobile-app-design.md §5 (Connection & auth): -// enter daemon URL -> GET /config to discover ZeroID -> API key or Google OAuth -> -// store token in the device keychain (expo-secure-store) -> WS attach. +import { discoverDaemon, loadCredentials, saveCredentials } from "@/lib/auth"; +import { openConnection } from "@/lib/connection"; +import { palette } from "@/lib/theme"; + +// Connect screen (design doc §5): daemon URL entry → /health + /config +// discovery → ZeroID API-key exchange → token in the device keychain → +// WS attach → session list. Google OAuth is P3 (gated on codeoid #42). export default function Connect() { const [daemonUrl, setDaemonUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + // "restoring" = probing stored credentials on launch, before showing the form. + const [phase, setPhase] = useState<"restoring" | "idle" | "connecting">("restoring"); + const [error, setError] = useState(null); + + const connect = useCallback(async (url: string, key: string) => { + setPhase("connecting"); + setError(null); + try { + const info = await discoverDaemon(url); + await openConnection({ daemonUrl: info.daemonUrl, apiKey: key }); + await saveCredentials({ daemonUrl: info.daemonUrl, apiKey: key }); + router.replace("/sessions"); + } catch (err) { + setPhase("idle"); + setError(err instanceof Error ? err.message : String(err)); + } + }, []); + + // Auto-connect with stored credentials; fall back to the form on any failure. + useEffect(() => { + let cancelled = false; + void (async () => { + const creds = await loadCredentials(); + if (cancelled) return; + if (!creds) { + setPhase("idle"); + return; + } + setDaemonUrl(creds.daemonUrl); + setApiKey(creds.apiKey); + await connect(creds.daemonUrl, creds.apiKey); + })(); + return () => { + cancelled = true; + }; + }, [connect]); + + const busy = phase !== "idle"; + const canSubmit = !busy && daemonUrl.trim().length > 0 && apiKey.trim().length > 0; return ( - + Codeoid Connect to your daemon + - Scaffold only — not yet functional. - + canSubmit && void connect(daemonUrl, apiKey)} + /> + + {error ? {error} : null} + + void connect(daemonUrl, apiKey)} + > + {busy ? ( + + ) : ( + Connect + )} + + + + {phase === "restoring" + ? "Checking saved connection…" + : "The API key is stored in the device keychain and exchanged for a short-lived token."} + + ); } @@ -33,18 +122,32 @@ const styles = StyleSheet.create({ justifyContent: "center", padding: 24, gap: 12, + backgroundColor: palette.bg, }, - title: { fontSize: 34, fontWeight: "700" }, - subtitle: { fontSize: 16, opacity: 0.7, marginBottom: 12 }, + title: { fontSize: 34, fontWeight: "700", color: palette.text }, + subtitle: { fontSize: 16, color: palette.textDim, marginBottom: 12 }, input: { width: "100%", maxWidth: 480, borderWidth: 1, - borderColor: "#3a3f47", + borderColor: palette.border, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 15, + color: palette.text, + backgroundColor: palette.surface, + }, + button: { + width: "100%", + maxWidth: 480, + borderRadius: 10, + paddingVertical: 13, + alignItems: "center", + backgroundColor: palette.accent, }, - hint: { fontSize: 13, opacity: 0.5, marginTop: 8 }, + buttonDisabled: { opacity: 0.4 }, + buttonLabel: { fontSize: 16, fontWeight: "600", color: palette.bg }, + error: { fontSize: 13, color: palette.red, maxWidth: 480 }, + hint: { fontSize: 13, color: palette.textDim, marginTop: 8, textAlign: "center", maxWidth: 480 }, }); diff --git a/app/session/[id].tsx b/app/session/[id].tsx new file mode 100644 index 0000000..9c28468 --- /dev/null +++ b/app/session/[id].tsx @@ -0,0 +1,236 @@ +import { Redirect, router, useLocalSearchParams } from "expo-router"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + FlatList, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { identityLabel } from "@codeoid/core"; +import type { SessionMessage } from "@codeoid/protocol"; + +import { getConnection, mintClientMsgId, type Connection } from "@/lib/connection"; +import { useConnectionStatus, useTranscript } from "@/lib/hooks"; +import { palette } from "@/lib/theme"; + +// Transcript view: attach on entry (incremental resume when a cursor exists), +// live-render via MessageStore.ingest(), detach on leave. Plain-text rows +// first (design doc §10 P1) — FlashList + streaming markdown arrive in P2. +export default function SessionScreen() { + const { id, name } = useLocalSearchParams<{ id: string; name?: string }>(); + const conn = getConnection(); + if (!conn || !id) return ; + return ; +} + +function Transcript({ + conn, + sessionId, + sessionName, +}: { + conn: Connection; + sessionId: string; + sessionName: string; +}) { + const insets = useSafeAreaInsets(); + const status = useConnectionStatus(conn); + const messages = useTranscript(conn, sessionId); + const [draft, setDraft] = useState(""); + const [sendError, setSendError] = useState(null); + const [attachError, setAttachError] = useState(null); + const listRef = useRef>(null); + + // (Re-)attach on every `connected` transition — the initial entry and any + // reconnect (a dropped socket loses the attachment). The resume cursor + // turns re-attach replays into incremental tails instead of full snapshots. + useEffect(() => { + if (status.kind !== "connected") return; + const resume = conn.cursors.resumeFor(sessionId); + conn.client + .request({ + type: "session.attach", + id: conn.client.nextId(), + sessionId, + ...(resume ? { resume } : {}), + }) + .then(() => setAttachError(null)) + .catch((err) => setAttachError(err instanceof Error ? err.message : String(err))); + }, [conn, sessionId, status.kind]); + + // Detach on leave so the daemon stops fanning broadcasts to this client. + useEffect( + () => () => { + if (conn.client.status.kind !== "connected") return; + try { + conn.client.send({ type: "session.detach", id: conn.client.nextId(), sessionId }); + } catch { + // Socket raced shut mid-teardown — the daemon reaps dead attachments. + } + }, + [conn, sessionId], + ); + + const onSend = useCallback(() => { + const text = draft.trim(); + if (!text || conn.client.status.kind !== "connected") return; + setDraft(""); + conn.client + .request({ + type: "session.send", + id: conn.client.nextId(), + sessionId, + text, + clientMsgId: mintClientMsgId(), + }) + .then(() => setSendError(null)) + .catch((err) => setSendError(err instanceof Error ? err.message : String(err))); + }, [conn, draft, sessionId]); + + return ( + + + router.back()} hitSlop={8}> + ‹ Sessions + + + {sessionName} + + {status.kind === "connected" ? "" : status.kind} + + + {attachError ? attach failed: {attachError} : null} + + m.messageId} + renderItem={({ item }) => } + contentContainerStyle={styles.listContent} + onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} + ListEmptyComponent={Waiting for scrollback…} + /> + + {sendError ? send failed: {sendError} : null} + + + + + Send + + + + ); +} + +function MessageRow({ msg }: { msg: SessionMessage }) { + const who = identityLabel(msg.identity); + if (msg.role === "tool_call" && msg.tool) { + const { tool } = msg; + const detail = + tool.state.phase === "waiting_confirmation" + ? `awaiting approval — ${tool.state.description}` + : tool.state.phase === "completed" + ? tool.state.success + ? "ok" + : "failed" + : tool.state.phase; + return ( + + + {who} · tool: {tool.name} + + + [{detail}]{msg.content ? `\n${msg.content}` : ""} + + + ); + } + return ( + + + {who} · {msg.role} + + + {msg.content} + + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: palette.bg }, + header: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: palette.border, + }, + back: { fontSize: 15, color: palette.accent }, + title: { flex: 1, fontSize: 16, fontWeight: "600", color: palette.text }, + status: { fontSize: 12, color: palette.amber }, + listContent: { padding: 16, gap: 14 }, + row: { gap: 4 }, + meta: { fontSize: 12, color: palette.textDim }, + content: { fontSize: 15, lineHeight: 21, color: palette.text }, + userContent: { color: palette.accent }, + toolText: { + fontSize: 13, + lineHeight: 18, + color: palette.textDim, + fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace", + }, + empty: { textAlign: "center", color: palette.textDim, marginTop: 48, fontSize: 14 }, + error: { color: palette.red, fontSize: 12, paddingHorizontal: 16, paddingVertical: 4 }, + composer: { + flexDirection: "row", + alignItems: "flex-end", + gap: 10, + paddingHorizontal: 12, + paddingTop: 10, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: palette.border, + backgroundColor: palette.surface, + }, + input: { + flex: 1, + maxHeight: 120, + borderWidth: 1, + borderColor: palette.border, + borderRadius: 10, + paddingHorizontal: 12, + paddingVertical: 8, + fontSize: 15, + color: palette.text, + backgroundColor: palette.bg, + }, + sendButton: { + borderRadius: 10, + paddingHorizontal: 16, + paddingVertical: 10, + backgroundColor: palette.accent, + }, + sendDisabled: { opacity: 0.4 }, + sendLabel: { fontSize: 15, fontWeight: "600", color: palette.bg }, +}); diff --git a/app/sessions.tsx b/app/sessions.tsx new file mode 100644 index 0000000..a53a404 --- /dev/null +++ b/app/sessions.tsx @@ -0,0 +1,184 @@ +import { Redirect, router } from "expo-router"; +import { useState } from "react"; +import { + FlatList, + Pressable, + RefreshControl, + StyleSheet, + Text, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { sessionAgentLabel } from "@codeoid/core"; +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 { palette, statusColor } from "@/lib/theme"; + +// Fleet view (design doc §8): the session list. Attach-on-select opens the +// transcript; the conductor home surface arrives in P5. +export default function Sessions() { + const conn = getConnection(); + if (!conn) return ; + return ; +} + +function SessionList({ conn }: { conn: NonNullable> }) { + const insets = useSafeAreaInsets(); + const status = useConnectionStatus(conn); + const { sessions, error, refresh } = useSessions(conn); + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = async () => { + setRefreshing(true); + await refresh(); + setRefreshing(false); + }; + + const daemonHost = conn.daemonUrl.replace(/^https?:\/\//, ""); + const protocolMismatch = + status.kind === "connected" && + status.auth.protocolVersion !== undefined && + status.auth.protocolVersion !== PROTOCOL_VERSION; + + const signOut = async () => { + closeConnection(); + await clearCredentials(); + router.replace("/"); + }; + + return ( + + + + Sessions + + {daemonHost} · {connectionLabel(status.kind)} + + + void signOut()} hitSlop={8}> + Sign out + + + + {status.kind === "reconnecting" ? ( + + ) : null} + {protocolMismatch && status.kind === "connected" ? ( + + ) : null} + {error ? : null} + + s.id} + renderItem={({ item }) => } + refreshControl={ + void onRefresh()} + tintColor={palette.textDim} + /> + } + ListEmptyComponent={ + + {sessions === null ? "Loading sessions…" : "No sessions on this daemon."} + + } + contentContainerStyle={{ paddingBottom: insets.bottom + 16 }} + /> + + ); +} + +function SessionRow({ session }: { session: SessionInfo }) { + return ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => + router.push({ + pathname: "/session/[id]", + params: { id: session.id, name: session.name }, + }) + } + > + + + + {session.name} + + + {sessionAgentLabel(session)} · {session.workdir} + + + {session.status} + + ); +} + +function Banner({ text, tone = "info" }: { text: string; tone?: "info" | "error" }) { + return ( + + {text} + + ); +} + +function connectionLabel(kind: string): string { + switch (kind) { + case "connected": + return "connected"; + case "connecting": + return "connecting…"; + case "reconnecting": + return "reconnecting…"; + default: + return kind; + } +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: palette.bg }, + header: { + flexDirection: "row", + alignItems: "center", + paddingHorizontal: 16, + paddingVertical: 12, + gap: 12, + }, + headerText: { flex: 1 }, + title: { fontSize: 24, fontWeight: "700", color: palette.text }, + daemon: { fontSize: 13, color: palette.textDim, marginTop: 2 }, + signOut: { fontSize: 14, color: palette.accent }, + banner: { + marginHorizontal: 16, + marginBottom: 8, + padding: 10, + borderRadius: 8, + backgroundColor: palette.surface, + borderWidth: 1, + borderColor: palette.border, + }, + bannerError: { borderColor: palette.red }, + bannerText: { fontSize: 13, color: palette.textDim }, + row: { + flexDirection: "row", + alignItems: "center", + gap: 12, + paddingHorizontal: 16, + paddingVertical: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: palette.border, + }, + rowPressed: { backgroundColor: palette.surface }, + statusDot: { width: 10, height: 10, borderRadius: 5 }, + rowBody: { flex: 1 }, + rowName: { fontSize: 16, fontWeight: "600", color: palette.text }, + rowMeta: { fontSize: 13, color: palette.textDim, marginTop: 2 }, + rowStatus: { fontSize: 12, color: palette.textDim }, + empty: { textAlign: "center", color: palette.textDim, marginTop: 48, fontSize: 14 }, +}); diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..8076768 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,24 @@ +// Learn more https://docs.expo.io/guides/customizing-metro +const { getDefaultConfig } = require('expo/metro-config'); + +/** @type {import('expo/metro-config').MetroConfig} */ +const config = getDefaultConfig(__dirname); + +// @codeoid/protocol and @codeoid/core ship raw TypeScript source with TS-ESM +// style relative imports ("./types.js" on disk as "types.ts"). Metro does not +// apply the .js -> .ts redirect inside node_modules, so retry failed .js +// resolutions without the extension and let sourceExts (ts, tsx, ...) match. +const defaultResolveRequest = config.resolver.resolveRequest; +config.resolver.resolveRequest = (context, moduleName, platform) => { + const resolve = defaultResolveRequest ?? context.resolveRequest; + try { + return resolve(context, moduleName, platform); + } catch (error) { + if (moduleName.endsWith('.js')) { + return resolve(context, moduleName.slice(0, -'.js'.length), platform); + } + throw error; + } +}; + +module.exports = config; diff --git a/package-lock.json b/package-lock.json index b7943c1..a5e075c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,21 +9,27 @@ "version": "0.0.1", "license": "Apache-2.0", "dependencies": { - "expo": "~57.0.0", + "@codeoid/core": "^0.1.0", + "@codeoid/protocol": "^0.1.0", + "@react-native-community/netinfo": "12.0.1", + "expo": "~57.0.4", "expo-constants": "~57.0.3", - "expo-linking": "~57.0.1", - "expo-router": "~57.0.3", + "expo-linking": "~57.0.2", + "expo-router": "~57.0.4", + "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", "react-native-web": "~0.21.0" }, "devDependencies": { "@types/react": "~19.2.0", "eslint": "^9.39.4", "eslint-config-expo": "^57.0.0", - "typescript": "~5.9.0" + "typescript": "~6.0.3" } }, "node_modules/@adobe/css-tools": { @@ -1173,6 +1179,29 @@ "node": ">=6.9.0" } }, + "node_modules/@codeoid/core": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@codeoid/core/-/core-0.1.0.tgz", + "integrity": "sha512-oPphUoRnlRCioXe3jXnHx9/NhYX8Q8f50KjsnQSriItITqJZSEP3nRP1EvsVYpTMTDTxud5W/pdWrMhvMI39Sw==", + "license": "MIT", + "peerDependencies": { + "@codeoid/protocol": "^0.1.0" + } + }, + "node_modules/@codeoid/protocol": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@codeoid/protocol/-/protocol-0.1.0.tgz", + "integrity": "sha512-wfl5QEau4VvGE2wW1O3LeUfsV9QmrVGG4fOvRCbDwtHTp8OHZnzCMwXTE/QkTeyBPG/mSboYmir/1T0CNYctxQ==", + "license": "MIT", + "peerDependencies": { + "zod": "^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1442,12 +1471,12 @@ } }, "node_modules/@expo/config": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.2.tgz", - "integrity": "sha512-J/K4fBPs/wGMrK445Mz5fCuFhsOZjSEq+u7F0yCboCwQu+uPlkT/ZCi6/q5pOunFOTICFtLB5wfCKqf9Iz5iKA==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.3.tgz", + "integrity": "sha512-IshqjjpGtT7wj86pxgsfMD1/abNMfu1wZ07ubyYO29l+PWa0eAh2TcpyLcyBaqo01IonF6646xWwrCPcdlUl3Q==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.2", + "@expo/config-plugins": "~57.0.3", "@expo/config-types": "^57.0.1", "@expo/json-file": "^11.0.0", "@expo/require-utils": "^57.0.1", @@ -1460,9 +1489,9 @@ } }, "node_modules/@expo/config-plugins": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.2.tgz", - "integrity": "sha512-85wEk9NKzdT25+UWEaSgF7gp9uR/GmtvrvQtGqZmyhFgVRNL2H3GdJOL4/50vyZh4JK69Hl7fWKGtARTN3B8Ag==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.3.tgz", + "integrity": "sha512-J3P2T7FoOE9P3TuLcJXwt8jISKRxo7UntTzbJ/Qc5F7QXenNntk/4t1XndVkLucYjpnHArPd9xzDXuxxKEHVbQ==", "license": "MIT", "dependencies": { "@expo/config-types": "^57.0.1", @@ -1558,9 +1587,9 @@ "license": "MIT" }, "node_modules/@expo/fingerprint": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.2.tgz", - "integrity": "sha512-8emNA6sG7UVEOhbvtsHHe4e/G7Xv/80Xt3Xhdqcg0ulRChTEDoXra7G1GqZeO0UXfXCzdpQl8RNKmWDOaB7gMA==", + "version": "0.20.3", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.3.tgz", + "integrity": "sha512-WDV2hS87C61TsX55w4A0cPnbnN8bqdVHp2MpZsJTvB4Tv4TITgSxWYhRj/xv0pwcZvKHn05MBg6AfHPNj9o5Sw==", "license": "MIT", "dependencies": { "@expo/env": "^2.4.1", @@ -1595,12 +1624,12 @@ } }, "node_modules/@expo/inline-modules": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.1.tgz", - "integrity": "sha512-Jbp1d6LSOS2RZCrNeK6JehcRGcYGb+1xNsHUfjggMWMlz/J2SP3HHKg8cbTkVj7KKPn33AkyFBaSqlMsvrp7ow==", + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.2.tgz", + "integrity": "sha512-wc5wH4ND647Ne/6A/ESuelcqOQUEvK8MYjjvbp5wcl59dECk7juh0cFOJjAWkpc4pLTcvqXBSRcUDpGWm5XNWg==", "license": "MIT", "dependencies": { - "@expo/config-plugins": "~57.0.1" + "@expo/config-plugins": "~57.0.3" } }, "node_modules/@expo/json-file": { @@ -1778,19 +1807,19 @@ } }, "node_modules/@expo/prebuild-config": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.4.tgz", - "integrity": "sha512-wdyu9aOvU0/vW3U2/HgekQKrxh9v8Rqq74LNUXMsMFv+LeJz3MJfnexJNAL243/TwqEO396QRGqCe7ac11x/mw==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.5.tgz", + "integrity": "sha512-KnKx6Iu4jppbNVtt9yTrN10OVoPZlOYYyLZuarY70u6fgUO9lYKDa06Hxv+PmIwcnygfj2CSBBl5ewBqS2bxtw==", "license": "MIT", "dependencies": { "@expo/config": "~57.0.2", - "@expo/config-plugins": "~57.0.2", + "@expo/config-plugins": "~57.0.3", "@expo/config-types": "^57.0.1", "@expo/image-utils": "^0.11.1", "@expo/json-file": "^11.0.0", "@react-native/normalize-colors": "0.86.0", "debug": "^4.3.1", - "expo-modules-autolinking": "~57.0.4", + "expo-modules-autolinking": "~57.0.5", "resolve-from": "^5.0.0", "semver": "^7.6.0" } @@ -1845,9 +1874,9 @@ "license": "MIT" }, "node_modules/@expo/ui": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.3.tgz", - "integrity": "sha512-iRzJYvKBDMVlVtm1POe0DM1JJS6KtbrZLnWTus0BHGH32hfQfjxKcNibvKVbHe6Ryjw8ayU1OfyFfL61bJKpFA==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/ui/-/ui-57.0.4.tgz", + "integrity": "sha512-QUotmKfb7GmVf+FSHvAdHeooL7JNSYOW3v4axaih45AHGmQVUKzh58uzcD4i2n3feK9tlFVDITHKAzBFagmJOg==", "license": "MIT", "dependencies": { "sf-symbols-typescript": "^2.1.0", @@ -2138,20 +2167,20 @@ } }, "node_modules/@radix-ui/react-dialog": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.18.tgz", - "integrity": "sha512-apa28mldjMgORmE6g/w3sCcA0Y9UAVeeDVoozN4i7kOw12mLl9RBchfzK3Nn6qxOWjrZhK1Lfy7f07kyzxtnBw==", + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.19.tgz", + "integrity": "sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", - "@radix-ui/react-context": "1.1.4", - "@radix-ui/react-dismissable-layer": "1.1.14", + "@radix-ui/react-context": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.1.15", "@radix-ui/react-focus-guards": "1.1.4", - "@radix-ui/react-focus-scope": "1.1.11", + "@radix-ui/react-focus-scope": "1.1.12", "@radix-ui/react-id": "1.1.2", "@radix-ui/react-portal": "1.1.13", - "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-presence": "1.1.7", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-slot": "1.3.0", "@radix-ui/react-use-controllable-state": "1.2.3", @@ -2173,6 +2202,50 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/primitive": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-context": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.2.0.tgz", + "integrity": "sha512-fOE+JtN9rygNZkCnHRBEP0TAvLldlhyOxMsbwFvTP4nAs+nBmfnna+o/Zski2wkmY1YMrFC0aSzsHoLY47iLrg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-presence": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.7.tgz", + "integrity": "sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", @@ -2189,12 +2262,12 @@ } }, "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.14.tgz", - "integrity": "sha512-4lUhWTWAjbDIqFrAPWJ3WqBOpO5YchVZ88X3nh6H9Lu5AFi5nCUeTPj3D8FSDmabmFeRe9ME0BDA4MwKTha5GQ==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.15.tgz", + "integrity": "sha512-b0XaRlzn2QKuo10XyNgi2DAJDf5XC9d1nD3FJcuvCjbR7+4Ad28zmZsLsqx+hvDEzMnRuZaZxZm9gYObV6RmRA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.4", + "@radix-ui/primitive": "1.1.5", "@radix-ui/react-compose-refs": "1.1.3", "@radix-ui/react-primitive": "2.1.7", "@radix-ui/react-use-callback-ref": "1.1.2", @@ -2215,6 +2288,12 @@ } } }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/primitive": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.5.tgz", + "integrity": "sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==", + "license": "MIT" + }, "node_modules/@radix-ui/react-focus-guards": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", @@ -2231,9 +2310,9 @@ } }, "node_modules/@radix-ui/react-focus-scope": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.11.tgz", - "integrity": "sha512-Mn88Vg2whaRocGJNOH+DKFqYm6ySFPQaiwHNxZPyjn99B52KAEJWWY9NP83+nWdk2HM3rdov+STu9AG471Rt9w==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.12.tgz", + "integrity": "sha512-jjk/lqTeNL0azUx5ZYzVrl4NgaDIrdzTNE4mABV9yBFI7FQqN7pIgzV1bTleUezP2QiTGA1BFTqY8MegDgWX9A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.3", @@ -2489,6 +2568,16 @@ } } }, + "node_modules/@react-native-community/netinfo": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/@react-native-community/netinfo/-/netinfo-12.0.1.tgz", + "integrity": "sha512-P/3caXIvfYSJG8AWJVefukg+ZGRPs+M4Lp3pNJtgcTYoJxCjWrKQGNnCkj/Cz//zWa/avGed0i/wzm0T8vV2IQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": ">=0.59" + } + }, "node_modules/@react-native-masked-view/masked-view": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz", @@ -4056,9 +4145,9 @@ } }, "node_modules/babel-preset-expo": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.1.tgz", - "integrity": "sha512-ClW79dx27GJVwKf/YMKCrR18uer6jlREZtKVB00HQMIoyeM5Qxh9axKTT0GUNFkFq596wi216ovp1zcGMUhE8g==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.2.tgz", + "integrity": "sha512-PgsWlNSlNhnSkU4UCAcvl252CaB7VU1eQuSvJoRSiqfrZDGmeqwQmMUs+PcS5VUppFwmmhVlDPwOc7kR3Qr08Q==", "license": "MIT", "dependencies": { "@babel/generator": "^7.20.5", @@ -4107,7 +4196,7 @@ "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", - "expo-widgets": "^57.0.1", + "expo-widgets": "^57.0.3", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -5762,31 +5851,31 @@ } }, "node_modules/expo": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.2.tgz", - "integrity": "sha512-QmyNQJNFJb/I6bQYpxl39jqyhCSlFXtiwBCyCFl3a7a18NZ8pHsVHTvLdRIXFI/bNXdCm/g7JMXoJB4eFKLBmg==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.4.tgz", + "integrity": "sha512-5wW0SR6OnGUh+UPb3JWlFTAL22IgBcX36RqofjLAH2AuFqxAIpYIdwr9BYR9Wl3xyIe0t9lCoMQIZlAIZUNQvg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "^57.0.4", - "@expo/config": "~57.0.2", - "@expo/config-plugins": "~57.0.2", + "@expo/cli": "^57.0.6", + "@expo/config": "~57.0.3", + "@expo/config-plugins": "~57.0.3", "@expo/devtools": "~57.0.0", "@expo/dom-webview": "~57.0.0", - "@expo/fingerprint": "^0.20.2", + "@expo/fingerprint": "^0.20.3", "@expo/local-build-cache-provider": "^57.0.2", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", "@expo/metro-config": "~57.0.3", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~57.0.1", + "babel-preset-expo": "~57.0.2", "expo-asset": "~57.0.3", "expo-constants": "~57.0.3", "expo-file-system": "~57.0.0", "expo-font": "~57.0.0", "expo-keep-awake": "~57.0.0", - "expo-modules-autolinking": "~57.0.4", - "expo-modules-core": "~57.0.2", + "expo-modules-autolinking": "~57.0.5", + "expo-modules-core": "~57.0.3", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", "whatwg-url-minimum": "^0.1.2" @@ -5897,12 +5986,12 @@ } }, "node_modules/expo-linking": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.1.tgz", - "integrity": "sha512-3Zr+UsTXPiUyGktGqqP0byXH02Yp7lsdmasenuZAMEn89cjD8Rs9j8o7kikjOzb+QnTR1lofgYu21QhpvCf6zg==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.2.tgz", + "integrity": "sha512-A7912NxC4ibDiv7fN2BuEWCrTCmmWdaD+bQa4iNddPDOa3Svs1YDoWdbhJt3mniSaAh9AnUKS2MdFbO4zk8z+Q==", "license": "MIT", "dependencies": { - "expo-constants": "~57.0.2", + "expo-constants": "~57.0.3", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5911,9 +6000,9 @@ } }, "node_modules/expo-modules-autolinking": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.4.tgz", - "integrity": "sha512-O0G2WRw4xtPpuhwufo2HQ6fyVQhNxtUVmO+tJD3+dB3bIslQCz3pM+s8ouhkf7ZU97g/wZOrd33umuHbbZjM3g==", + "version": "57.0.5", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.5.tgz", + "integrity": "sha512-Vv2PVeEUN0/VW19ItkVA8LxAuH8SV8cjui0MKhJxCKrNyCAGGZU6sRsBJciYXR4uVxuXWKiepGW6E3k/c+Ytdg==", "license": "MIT", "dependencies": { "@expo/require-utils": "^57.0.1", @@ -5926,13 +6015,13 @@ } }, "node_modules/expo-modules-core": { - "version": "57.0.2", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.2.tgz", - "integrity": "sha512-gs1Ng2Ci1C/CwN1xRZp2RR74C9iWByf9AHaovYEtOlkly9AolitQGAt9+iLT0CoCb6xw128NcDQ00OJl/Bmv9Q==", + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.3.tgz", + "integrity": "sha512-fuD3DjPQvdaldtCJm2erV2/trPMrDJvEwPdz0RuxAQnduiNwZ3yxBoi81ZEKC5GBSJauMo53YXSwogsFagjwFQ==", "license": "MIT", "dependencies": { "@expo/expo-modules-macros-plugin": "0.3.0", - "expo-modules-jsi": "~57.0.0", + "expo-modules-jsi": "~57.0.1", "invariant": "^2.2.4" }, "peerDependencies": { @@ -5947,24 +6036,24 @@ } }, "node_modules/expo-modules-jsi": { - "version": "57.0.0", - "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.0.0.tgz", - "integrity": "sha512-lNcA2XLKpbG/Qr3CZ6yCgzlK8oT+zwuD19QKYoRfN5ZurkVhnSA3QdTR5K32n9AxohcENYtZRtnHr2pZoG7W4w==", + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.0.1.tgz", + "integrity": "sha512-dECN3pOFv+KrQcGrOhJKEBc1/ob7SBjTTYGRB1bc84zmQ65La0FSrAPxpvnEQf8g9giLB5y9RLqwGxHspjXigQ==", "license": "MIT", "peerDependencies": { "react-native": "*" } }, "node_modules/expo-router": { - "version": "57.0.3", - "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.3.tgz", - "integrity": "sha512-Pm9CYn6WSyTM3pfmUJxQHkS/bzCzX1G+IJBRsXLE/MyOOWyfkM/Lu1/UiAlCyHOCuCWe2SuA5jrIE00+5qF9pQ==", + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-57.0.4.tgz", + "integrity": "sha512-JVUAxamOQV7oG/uo/itLuR8gLiN3+C00bg/JPU+p/g/8Mx3PceZ4Mga/34cLmMc+RETpi5EjC/+AN0mJJzOW3w==", "license": "MIT", "dependencies": { "@expo/log-box": "^57.0.0", "@expo/metro-runtime": "^57.0.3", "@expo/schema-utils": "^57.0.1", - "@expo/ui": "^57.0.3", + "@expo/ui": "^57.0.4", "@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-native-masked-view/masked-view": "^0.3.2", @@ -5997,7 +6086,7 @@ "@testing-library/react-native": ">= 13.2.0", "expo": "*", "expo-constants": "^57.0.3", - "expo-linking": "^57.0.1", + "expo-linking": "^57.0.2", "react": "*", "react-dom": "*", "react-native": "*", @@ -6029,6 +6118,15 @@ } } }, + "node_modules/expo-secure-store": { + "version": "57.0.0", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.0.tgz", + "integrity": "sha512-vkP16rhW7b4bljW5BC4kKXBpNxQ0O1E9SpI5NIfh2biZnszLTpI/gUF4oBsvOY2nvkh7oXS2ERuUoA8cuS8FWQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.0.tgz", @@ -6066,18 +6164,18 @@ } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "57.0.4", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.4.tgz", - "integrity": "sha512-7d+YW9PdGqgNI4dh9FTv+ZNE2xu1jV8xREDgl/7jiKNcOKdgby6ZAXufZX7iRotyxyu8fwjzETKMg7MkmYLJ8A==", + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.6.tgz", + "integrity": "sha512-14wEb2e8lctlxGARbinUEr+9EmrTR2jIgcaPBBkZXDFMRJmdoY0ic18JuFJPi+7CuQ4XWiGK6obN4VK2PXosLA==", "license": "MIT", "dependencies": { "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~57.0.2", - "@expo/config-plugins": "~57.0.2", + "@expo/config": "~57.0.3", + "@expo/config-plugins": "~57.0.3", "@expo/devcert": "^1.2.1", "@expo/env": "~2.4.1", "@expo/image-utils": "^0.11.1", - "@expo/inline-modules": "^0.1.1", + "@expo/inline-modules": "^0.1.2", "@expo/json-file": "^11.0.0", "@expo/log-box": "^57.0.0", "@expo/metro": "~56.0.0", @@ -6086,9 +6184,9 @@ "@expo/osascript": "^2.7.0", "@expo/package-manager": "^1.13.0", "@expo/plist": "^0.8.0", - "@expo/prebuild-config": "^57.0.4", + "@expo/prebuild-config": "^57.0.5", "@expo/require-utils": "^57.0.1", - "@expo/router-server": "^57.0.1", + "@expo/router-server": "^57.0.2", "@expo/schema-utils": "^57.0.1", "@expo/spawn-async": "^1.8.0", "@expo/ws-tunnel": "^2.0.0", @@ -6148,17 +6246,17 @@ } }, "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { - "version": "57.0.1", - "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.1.tgz", - "integrity": "sha512-jZ+jHG34rRa8HyurTQq5r558dMcb77F3Mt0HMKotAJ8Wm5bJLULJsL3KVLkEVcYP254xI9zzeJVcN79Fck/nLQ==", + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.2.tgz", + "integrity": "sha512-hu9qhnq2PKuwMXIoRMAa2+HUeqSut9HwiQf3a62tfGTIxPN3rKUZ10TozEEB+Pd/7WIa238sXMS5v1YhTuC9gA==", "license": "MIT", "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { - "@expo/metro-runtime": "^57.0.2", + "@expo/metro-runtime": "^57.0.3", "expo": "*", - "expo-constants": "^57.0.2", + "expo-constants": "^57.0.3", "expo-font": "^57.0.0", "expo-router": "*", "expo-server": "^57.0.0", @@ -6269,6 +6367,15 @@ } } }, + "node_modules/expo/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -9688,11 +9795,10 @@ } }, "node_modules/react-native-safe-area-context": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.8.0.tgz", - "integrity": "sha512-t+ZsAVzY/wWzzx34vqGbo3/as9EEESJdbyZNL7Yg5EYX+toYMtMqFoDDCvqZUi35eeGVsXc6pAaEk4edMwbuCQ==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11076,9 +11182,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -11694,9 +11800,10 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 3dadb87..2192a23 100644 --- a/package.json +++ b/package.json @@ -18,20 +18,26 @@ "lint": "eslint ." }, "dependencies": { - "expo": "~57.0.0", + "@codeoid/core": "^0.1.0", + "@codeoid/protocol": "^0.1.0", + "@react-native-community/netinfo": "12.0.1", + "expo": "~57.0.4", "expo-constants": "~57.0.3", - "expo-linking": "~57.0.1", - "expo-router": "~57.0.3", + "expo-linking": "~57.0.2", + "expo-router": "~57.0.4", + "expo-secure-store": "~57.0.0", "expo-status-bar": "~57.0.0", "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.0", + "react-native-safe-area-context": "~5.7.0", + "react-native-screens": "4.25.2", "react-native-web": "~0.21.0" }, "devDependencies": { "@types/react": "~19.2.0", "eslint": "^9.39.4", "eslint-config-expo": "^57.0.0", - "typescript": "~5.9.0" + "typescript": "~6.0.3" } } diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..f0f637a --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,196 @@ +/** + * Daemon discovery + ZeroID API-key auth (design doc §5). + * + * React Native port of the daemon web UI's `lib/auth.ts` resolve flow: + * + * 1. `GET /health` + `GET /config` on the daemon origin — reachability, + * version, and the daemon's configured ZeroID URL. + * 2. POST `grant_type=api_key` (a `zid_sk_…` key) to the daemon's + * same-origin `/oauth2/token` proxy → short-lived JWT. + * 3. The durable credential (daemon URL + API key) lives in the device + * Keychain/Keystore via expo-secure-store; the JWT itself is held only + * in memory and re-minted on every reconnect. + * + * Google OAuth (design doc P3) is gated on codeoid #42 — P1 is API-key only. + */ +import * as SecureStore from "expo-secure-store"; + +const KEY_DAEMON_URL = "codeoid.daemonUrl"; +const KEY_API_KEY = "codeoid.apiKey"; + +/** + * Scopes requested on every api_key → JWT exchange. ZeroID propagates these + * into the JWT's `scopes` claim, which the daemon enforces per protocol verb — + * omitting them yields a scope-less JWT where every verb is denied. Mirrors + * the web UI's operator set minus the conductor scopes (P5). + */ +export const DEFAULT_MOBILE_SCOPES = [ + "session:list", + "session:create", + "session:attach", + "session:watch", + "session:send", + "session:interrupt", + "session:approve", + "session:destroy", + "fs:read", +].join(" "); + +export class AuthError extends Error { + constructor( + message: string, + public readonly kind: "invalid" | "unreachable" | "exchange_failed", + public readonly cause?: unknown, + ) { + super(message); + this.name = "AuthError"; + } +} + +/** Normalize user input to a bare origin: scheme + host[:port], no path. */ +export function normalizeDaemonUrl(input: string): string { + const trimmed = input.trim(); + if (!trimmed) throw new AuthError("enter a daemon URL", "invalid"); + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`; + let url: URL; + try { + url = new URL(withScheme); + } catch { + throw new AuthError(`"${trimmed}" is not a valid URL`, "invalid"); + } + return `${url.protocol}//${url.host}`; +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (err) { + throw new AuthError(`cannot reach ${url}`, "unreachable", err); + } finally { + clearTimeout(timer); + } +} + +export interface DaemonInfo { + /** Normalized daemon origin. */ + daemonUrl: string; + /** Daemon version from /health, when reported. */ + version: string | null; + /** ZeroID base URL from /config (informational — the exchange goes through + * the daemon's same-origin proxy, so mobile never hits ZeroID directly). */ + zeroidUrl: string | null; +} + +/** Probe a daemon origin: /health must answer; /config is best-effort. */ +export async function discoverDaemon(rawUrl: string, timeoutMs = 8_000): Promise { + const daemonUrl = normalizeDaemonUrl(rawUrl); + + const health = await fetchWithTimeout(`${daemonUrl}/health`, {}, timeoutMs); + if (!health.ok) { + throw new AuthError( + `daemon /health answered ${health.status} — is this a codeoid daemon?`, + "unreachable", + ); + } + const healthBody = (await health.json().catch(() => ({}))) as { version?: unknown }; + + let zeroidUrl: string | null = null; + try { + const config = await fetchWithTimeout(`${daemonUrl}/config`, {}, timeoutMs); + if (config.ok) { + const body = (await config.json()) as { zeroid_url?: unknown }; + if (typeof body.zeroid_url === "string") zeroidUrl = body.zeroid_url; + } + } catch { + // /config is optional — the token proxy is same-origin regardless. + } + + return { + daemonUrl, + version: typeof healthBody.version === "string" ? healthBody.version : null, + zeroidUrl, + }; +} + +/** + * Exchange a ZeroID API key for a short-lived JWT via the daemon's + * same-origin `/oauth2/token` proxy. Called on sign-in and again on every + * reconnect (`CodeoidClient.getToken`) so an expired JWT never wedges the + * socket. + */ +export async function exchangeApiKey( + daemonUrl: string, + apiKey: string, + scope: string = DEFAULT_MOBILE_SCOPES, + timeoutMs = 15_000, +): Promise { + const key = apiKey.trim(); + if (!key.startsWith("zid_sk_")) { + throw new AuthError( + `api key must start with "zid_sk_" — got "${key.slice(0, 8)}…"`, + "invalid", + ); + } + + const res = await fetchWithTimeout( + `${daemonUrl}/oauth2/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ grant_type: "api_key", api_key: key, scope }).toString(), + }, + timeoutMs, + ); + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new AuthError( + `ZeroID rejected the API key (${res.status}): ${body.slice(0, 200) || res.statusText}`, + "exchange_failed", + ); + } + + let payload: unknown; + try { + payload = await res.json(); + } catch (err) { + throw new AuthError("token endpoint returned non-JSON", "exchange_failed", err); + } + const token = (payload as { access_token?: unknown }).access_token; + if (typeof token !== "string" || token.length === 0) { + throw new AuthError("token response missing access_token", "exchange_failed"); + } + return token; +} + +// ── Credential persistence (Keychain / Keystore) ──────────────────────────── + +export interface StoredCredentials { + daemonUrl: string; + apiKey: string; +} + +export async function saveCredentials(creds: StoredCredentials): Promise { + await SecureStore.setItemAsync(KEY_DAEMON_URL, creds.daemonUrl); + await SecureStore.setItemAsync(KEY_API_KEY, creds.apiKey); +} + +export async function loadCredentials(): Promise { + const [daemonUrl, apiKey] = await Promise.all([ + SecureStore.getItemAsync(KEY_DAEMON_URL), + SecureStore.getItemAsync(KEY_API_KEY), + ]); + if (!daemonUrl || !apiKey) return null; + return { daemonUrl, apiKey }; +} + +export async function clearCredentials(): Promise { + await SecureStore.deleteItemAsync(KEY_DAEMON_URL); + await SecureStore.deleteItemAsync(KEY_API_KEY); +} diff --git a/src/lib/connection.ts b/src/lib/connection.ts new file mode 100644 index 0000000..5dfc8db --- /dev/null +++ b/src/lib/connection.ts @@ -0,0 +1,135 @@ +/** + * The app-wide daemon connection singleton (design doc §4). + * + * One `CodeoidClient` (reconnecting WS transport) + one `MessageStore` + * (transcript accumulation) + one `ResumeCursors` (incremental re-attach) + * per daemon connection. Every daemon broadcast is routed through + * `store.ingest()` regardless of which screen is mounted, so transcripts + * keep accumulating while the user is on the session list. + * + * Native resume wiring: the core client's focus/online listeners are + * browser-only, so React Native `AppState` (foreground) and NetInfo + * (connectivity regained) call `reconnectNow()` instead. A zombie socket + * that survived suspension without a close event is caught by the client's + * own liveness heartbeat within one cadence (~20s). + */ +import NetInfo from "@react-native-community/netinfo"; +import { AppState } from "react-native"; +import { CodeoidClient, MessageStore, ResumeCursors } from "@codeoid/core"; +import { CAPABILITIES } from "@codeoid/protocol"; + +import { exchangeApiKey } from "./auth"; + +export const CLIENT_NAME = "codeoid-mobile/0.0.1"; + +export interface Connection { + daemonUrl: string; + client: CodeoidClient; + store: MessageStore; + cursors: ResumeCursors; +} + +let current: Connection | null = null; +let teardown: (() => void) | null = null; + +function wsUrlFor(daemonUrl: string): string { + // http(s) origin → ws(s) endpoint at the origin root. + return daemonUrl.replace(/^http/i, "ws"); +} + +function withTimeout(promise: Promise, ms: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + promise.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (e) => { + clearTimeout(timer); + reject(e); + }, + ); + }); +} + +/** + * Open (replacing any previous) connection: exchange the API key for a JWT, + * connect the WS, and wire native resume signals. Resolves once `auth.ok` + * lands; a connect that can't complete within `connectTimeoutMs` tears the + * client down and rejects so the sign-in screen can surface the failure. + */ +export async function openConnection(opts: { + daemonUrl: string; + apiKey: string; + connectTimeoutMs?: number; +}): Promise { + closeConnection(); + + const token = await exchangeApiKey(opts.daemonUrl, opts.apiKey); + const client = new CodeoidClient({ + url: wsUrlFor(opts.daemonUrl), + token, + // Re-exchange on every reconnect — JWTs are short-lived and the daemon + // closes 4003 on an expired token. + getToken: () => exchangeApiKey(opts.daemonUrl, opts.apiKey), + capabilities: [ + CAPABILITIES.PARTS, + CAPABILITIES.CHUNKED_REPLAY, + CAPABILITIES.SEQ_RESUME, + CAPABILITIES.SEND_IDEMPOTENCY, + ], + clientName: CLIENT_NAME, + }); + + const store = new MessageStore(); + const cursors = new ResumeCursors(); + client.onMessage((msg) => { + store.ingest(msg, cursors); + }); + + const appStateSub = AppState.addEventListener("change", (state) => { + if (state === "active") client.reconnectNow(); + }); + const netInfoUnsub = NetInfo.addEventListener((state) => { + if (state.isConnected) client.reconnectNow(); + }); + teardown = () => { + appStateSub.remove(); + netInfoUnsub(); + client.shutdown(); + }; + current = { daemonUrl: opts.daemonUrl, client, store, cursors }; + + try { + await withTimeout( + client.connect(), + opts.connectTimeoutMs ?? 30_000, + "daemon WebSocket connect timed out", + ); + } catch (err) { + closeConnection(); + throw err; + } + return current; +} + +export function getConnection(): Connection | null { + return current; +} + +/** Shut down the transport and drop all connection state (sign-out). */ +export function closeConnection(): void { + teardown?.(); + teardown = null; + current = null; +} + +/** + * Idempotency key for `session.send` (`send.idempotency` capability) — + * minted ONCE per user action so a retry after ambiguous delivery can't + * turn one prompt into two billed turns. + */ +export function mintClientMsgId(): string { + return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/src/lib/hooks.ts b/src/lib/hooks.ts new file mode 100644 index 0000000..70b4ecf --- /dev/null +++ b/src/lib/hooks.ts @@ -0,0 +1,108 @@ +/** + * React bindings over the framework-agnostic @codeoid/core primitives. + * The store/client own the state; these hooks only subscribe and re-render. + */ +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; +import type { ClientStatus } from "@codeoid/core"; +import type { + SessionInfo, + SessionListResultMsg, + SessionMessage, +} from "@codeoid/protocol"; + +import type { Connection } from "./connection"; + +/** Live transport status (idle / connecting / connected / reconnecting / failed). */ +export function useConnectionStatus(conn: Connection): ClientStatus { + return useSyncExternalStore( + useCallback((onChange: () => void) => conn.client.onStatus(onChange), [conn]), + () => conn.client.status, + ); +} + +/** + * The daemon's session list. Fetched on every `connected` transition (initial + * connect and reconnects), then kept live from `session.status_change` / + * `session.info_update` broadcasts. `refresh()` re-pulls on demand. + */ +export function useSessions(conn: Connection): { + sessions: SessionInfo[] | null; + error: string | null; + refresh: () => Promise; +} { + const [sessions, setSessions] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + if (conn.client.status.kind !== "connected") return; + const id = conn.client.nextId(); + try { + const result = await conn.client.request( + { type: "session.list", id }, + { + waitForResult: (m) => + m.type === "session.list.result" && m.requestId === id ? m : undefined, + }, + ); + setSessions(result.sessions); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }, [conn]); + + // Re-pull on every `connected` transition (initial connect + reconnects). + // onStatus fires the handler immediately on subscribe, covering the case + // where the client connected before this hook mounted. + useEffect(() => { + let last: string | null = null; + return conn.client.onStatus((s) => { + if (s.kind === "connected" && last !== "connected") void refresh(); + last = s.kind; + }); + }, [conn, refresh]); + + useEffect( + () => + conn.client.onMessage((msg) => { + if (msg.type === "session.status_change") { + setSessions((prev) => + prev + ? prev.map((s) => (s.id === msg.sessionId ? { ...s, status: msg.status } : s)) + : prev, + ); + } else if (msg.type === "session.info_update") { + setSessions((prev) => { + if (!prev) return prev; + const known = prev.some((s) => s.id === msg.session.id); + return known + ? prev.map((s) => (s.id === msg.session.id ? msg.session : s)) + : [...prev, msg.session]; + }); + } + }), + [conn], + ); + + return { sessions, error, refresh }; +} + +/** + * A session's transcript, re-read on every store epoch bump (message upsert, + * streaming delta, replay). Returns a fresh array per epoch so list views + * see a new identity and re-render. + */ +export function useTranscript(conn: Connection, sessionId: string): SessionMessage[] { + const epoch = useSyncExternalStore( + useCallback( + (onChange: () => void) => + conn.store.onChange((sid) => { + if (sid === sessionId) onChange(); + }), + [conn, sessionId], + ), + () => conn.store.epochOf(sessionId), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps -- epoch is the change signal for the store's live array + return useMemo(() => [...conn.store.messagesFor(sessionId)], [conn, sessionId, epoch]); +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..7031324 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,29 @@ +import type { SessionStatus } from "@codeoid/protocol"; + +/** Shared palette — terminal-adjacent dark UI, consistent across screens. */ +export const palette = { + bg: "#0d1117", + surface: "#161b22", + border: "#30363d", + text: "#e6edf3", + textDim: "#8a8f98", + accent: "#58a6ff", + green: "#3fb950", + amber: "#d29922", + red: "#f85149", +} as const; + +/** Status → indicator color, mirroring the web UI's semantics. */ +export function statusColor(status: SessionStatus): string { + switch (status) { + case "thinking": + case "tool_running": + return palette.amber; + case "waiting_approval": + case "error": + return palette.red; + case "idle": + default: + return palette.green; + } +}