diff --git a/apps/mobile/src/screens/connection-screen.tsx b/apps/mobile/src/screens/connection-screen.tsx index 923914e..2f36adb 100644 --- a/apps/mobile/src/screens/connection-screen.tsx +++ b/apps/mobile/src/screens/connection-screen.tsx @@ -506,7 +506,7 @@ export function ConnectionScreen({ onDone, onPair }: { onDone?: () => void; onPa pressed && styles.secondaryButtonPressed, ]} > - PAIR SERVER + NOTIFICATIONS + PAIR SERVER ) : null} diff --git a/apps/mobile/src/screens/notification-pairing-screen.test.tsx b/apps/mobile/src/screens/notification-pairing-screen.test.tsx new file mode 100644 index 0000000..a001b6f --- /dev/null +++ b/apps/mobile/src/screens/notification-pairing-screen.test.tsx @@ -0,0 +1,130 @@ +import { act, fireEvent, render, screen } from "@testing-library/react-native"; +import { NotificationPairingScreen } from "./notification-pairing-screen"; + +const mockSave = jest.fn(async () => "connection-1"); +const mockHealth = jest.fn(async () => ({ pid: 42, version: "test" })); +const mockCreateClient = jest.fn((..._args: unknown[]) => ({ + health: { get: mockHealth }, + server: { get: jest.fn(async () => ({ urls: [] })) }, + session: { list: jest.fn(async () => ({ data: [] })) }, +})); +const mockRegisterPush = jest.fn( + async (): Promise<{ deviceName: string; expoPushToken: string; platform: string }> => { + throw new Error("NOTIFICATION_PERMISSION_DENIED"); + }, +); + +jest.mock("expo-camera", () => ({ + CameraView: () => null, + useCameraPermissions: () => [{ granted: true }, jest.fn()], +})); +jest.mock("expo-sqlite", () => ({ useSQLiteContext: () => ({}) })); +jest.mock("expo-crypto", () => ({ randomUUID: () => "test-id" })); +jest.mock("../connections/connections-context", () => ({ + useConnections: () => ({ save: mockSave }), +})); +jest.mock("../expo-open-code-fetch", () => ({ boundedOpenCodeFetch: jest.fn() })); +jest.mock("@opencode2-mobile/opencode-adapter", () => ({ + createOpenCodeClient: (...args: unknown[]) => mockCreateClient(...args), + normalizeOpenCodeBaseUrl: (value: string) => new URL(value).origin, +})); +jest.mock("../notifications/notification-registration", () => ({ + registerForOpenCodePushNotifications: () => mockRegisterPush(), +})); +jest.mock("../notifications/notification-pairing-repository", () => ({})); + +beforeEach(() => { + jest.clearAllMocks(); + mockHealth.mockResolvedValue({ pid: 42, version: "test" }); +}); + +async function inspectCode(url = "https://server.test") { + await fireEvent.changeText( + screen.getByPlaceholderText("Pairing code"), + JSON.stringify({ urls: [url], username: "opencode", password: "test-password" }), + ); + await fireEvent.press(screen.getByText("CHECK CODE")); +} + +test("pairs directly without push permission or a notification broker", async () => { + const done = jest.fn(); + await render(); + await inspectCode(); + await act(async () => { + fireEvent.press(screen.getByText("PAIR WITHOUT NOTIFICATIONS")); + }); + expect(mockRegisterPush).not.toHaveBeenCalled(); + expect(mockCreateClient).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "https://server.test", + authorization: expect.stringMatching(/^Basic /), + }), + ); + expect(mockSave).toHaveBeenCalledWith( + expect.objectContaining({ + credential: { + mode: "basic", + username: "opencode", + password: "test-password", + schemaVersion: 1, + }, + draft: expect.objectContaining({ + baseUrl: "https://server.test", + allowDevelopmentHttp: false, + }), + }), + ); + expect(done).toHaveBeenCalledTimes(1); +}); + +test("automatically pairs after notification registration fails", async () => { + await render(); + await inspectCode(); + await act(async () => { + fireEvent.press(screen.getByText("PAIR AND SAVE")); + }); + expect(mockSave).toHaveBeenCalledTimes(1); +}); + +test("automatically pairs when the broker is unreachable", async () => { + mockRegisterPush.mockResolvedValueOnce({ + deviceName: "Phone", + expoPushToken: "test-token", + platform: "ios", + }); + const fetch = jest + .spyOn(globalThis, "fetch") + .mockRejectedValueOnce(new TypeError("Network request failed")); + const done = jest.fn(); + try { + await render(); + await inspectCode("http://server.test:4096"); + await act(async () => { + fireEvent.press(screen.getByText("APPROVE HTTP + PAIR")); + }); + expect(fetch).toHaveBeenCalledWith( + "http://server.test:37100/v1/pair/opencode", + expect.anything(), + ); + expect(mockSave).toHaveBeenCalledTimes(1); + expect(done).toHaveBeenCalledTimes(1); + } finally { + fetch.mockRestore(); + } +}); + +test("requires explicit HTTP approval and does not save a failed connection", async () => { + mockHealth.mockRejectedValueOnce(new Error("unreachable")); + const done = jest.fn(); + await render(); + await inspectCode("http://server.test:4096"); + expect(screen.queryByText("PAIR WITHOUT NOTIFICATIONS")).toBeNull(); + await act(async () => { + fireEvent.press(screen.getByText("APPROVE HTTP + PAIR WITHOUT NOTIFICATIONS")); + }); + expect(mockSave).not.toHaveBeenCalled(); + expect(done).not.toHaveBeenCalled(); + expect( + screen.getByText("The paired OpenCode address or credentials could not be validated."), + ).toBeTruthy(); +}); diff --git a/apps/mobile/src/screens/notification-pairing-screen.tsx b/apps/mobile/src/screens/notification-pairing-screen.tsx index a1a4a47..4f5241c 100644 --- a/apps/mobile/src/screens/notification-pairing-screen.tsx +++ b/apps/mobile/src/screens/notification-pairing-screen.tsx @@ -9,6 +9,7 @@ import { StatusBar } from "expo-status-bar"; import { useRef, useState } from "react"; import { ActivityIndicator, + Alert, KeyboardAvoidingView, Platform, Pressable, @@ -91,6 +92,61 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { setScanning(true); } + async function pairWithoutNotifications() { + if (preview?.kind !== "opencode" || busy) return; + await connectDirectly(preview.prepared); + } + + async function connectDirectly( + prepared: ReturnType, + notificationFallback = false, + ) { + setBusy(true); + setError(undefined); + let stage: PairingStage = "opencode-validation"; + try { + const credential: ConnectionCredential = { + mode: "basic", + password: prepared.code.password, + schemaVersion: 1, + username: prepared.code.username, + }; + const baseUrl = normalizeOpenCodeBaseUrl(prepared.openCodeOrigin); + const client = createOpenCodeClient({ + authorization: connectionAuthorizationHeader(credential), + baseUrl, + fetch: boundedOpenCodeFetch, + }); + const [health] = await Promise.all([ + client.health.get(), + client.server.get(), + client.session.list({ limit: 1, order: "desc" }), + ]); + stage = "connection-save"; + await connections.save({ + credential, + draft: { + allowDevelopmentHttp: prepared.allowDevelopmentHttp, + authMode: "basic", + baseUrl, + name: prepared.name, + }, + health: { checkedAtMs: Date.now(), pid: health.pid, version: health.version }, + }); + if (notificationFallback) { + Alert.alert( + "Server connected", + "Notification setup was unavailable. Connected without notifications.", + ); + } + onDone(); + } catch (caught) { + setError(pairingErrorMessage(caught, stage)); + } finally { + setBusy(false); + } + } + async function pair() { if (!preview || busy) return; setBusy(true); @@ -190,6 +246,17 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { connectionRollbackFailed = true; } } + if ( + preview.kind === "opencode" && + !savedConnectionId && + (stage === "push-registration" || + stage === "broker-issue" || + stage === "secure-storage" || + stage === "broker-registration") + ) { + await connectDirectly(preview.prepared, true); + return; + } setError( connectionRollbackFailed ? "Pairing failed after saving the connection. Remove it from Connections before retrying." @@ -220,8 +287,8 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { - The code configures one OpenCode server and its notification broker. Credentials are - decrypted on this phone and stored in the device keychain. + Scan an OpenCode /pair code to connect. Notifications are set up when available; + otherwise the app connects without them. Credentials are stored in the device keychain. {scanning ? ( @@ -305,6 +372,20 @@ export function NotificationPairingScreen({ onDone }: { onDone: () => void }) { )} + {preview.kind === "opencode" ? ( + void pairWithoutNotifications()} + style={({ pressed }) => [styles.inspectButton, pressed && styles.pressed]} + > + + {preview.prepared.allowDevelopmentHttp + ? "APPROVE HTTP + PAIR WITHOUT NOTIFICATIONS" + : "PAIR WITHOUT NOTIFICATIONS"} + + + ) : null} ) : null} {error ? (