diff --git a/package-lock.json b/package-lock.json index 4efeeea29..d23062496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8899,6 +8899,16 @@ "form-data": "^4.0.4" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -11794,6 +11804,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-uri-component": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", @@ -11951,6 +11970,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -13769,6 +13794,26 @@ "node": ">=10" } }, + "node_modules/expo-camera": { + "version": "17.0.10", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", + "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -19590,6 +19635,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qrcode-terminal": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", @@ -19599,6 +19661,142 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, + "node_modules/qrcode/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", @@ -20528,6 +20726,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/requireg": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", @@ -21109,6 +21313,12 @@ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -23911,6 +24121,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", @@ -24856,6 +25072,7 @@ "expo-audio": "~1.0.13", "expo-av": "^16.0.7", "expo-build-properties": "^1.0.9", + "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.7", "expo-constants": "~18.0.9", "expo-crypto": "^15.0.8", @@ -24979,6 +25196,7 @@ "pino": "^10.2.0", "pino-pretty": "^13.1.3", "playwright": "^1.56.1", + "qrcode": "^1.5.4", "tiny-invariant": "^1.3.3", "uuid": "^9.0.1", "ws": "^8.14.2", @@ -24988,6 +25206,7 @@ "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", + "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", diff --git a/packages/app/app.config.js b/packages/app/app.config.js index 1f538a1e4..4f7492a8a 100644 --- a/packages/app/app.config.js +++ b/packages/app/app.config.js @@ -42,6 +42,8 @@ export default { "RECORD_AUDIO", "android.permission.RECORD_AUDIO", "android.permission.MODIFY_AUDIO_SETTINGS", + "CAMERA", + "android.permission.CAMERA", ], // Base package - Gradle product flavors override this per variant package: "com.moboudra.paseo", @@ -52,6 +54,12 @@ export default { }, plugins: [ "expo-router", + [ + "expo-camera", + { + cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.", + }, + ], [ "expo-splash-screen", { diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index cbcb12f5f..895349b3a 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -36,6 +36,7 @@ test.beforeEach(async ({ page }) => { }); const nowIso = new Date().toISOString(); + const seedNonce = Math.random().toString(36).slice(2); const testDaemon = { id: 'e2e-test-daemon', label: 'localhost', @@ -58,24 +59,28 @@ test.beforeEach(async ({ page }) => { }; await page.addInitScript( - ({ daemon, preferences }) => { + ({ daemon, preferences, seedNonce }) => { // `addInitScript` runs on every navigation (including reloads). Some tests intentionally // override storage and reload; they can opt out of seeding for the *next* navigation by // setting this flag before the reload. const disableOnceKey = '@paseo:e2e-disable-default-seed-once'; - if (localStorage.getItem(disableOnceKey)) { + const disableValue = localStorage.getItem(disableOnceKey); + if (disableValue) { localStorage.removeItem(disableOnceKey); - return; + if (disableValue === seedNonce) { + return; + } } localStorage.setItem('@paseo:e2e', '1'); + localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce); // Hard-reset anything that could point to a developer's real daemon. localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.removeItem('@paseo:settings'); localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); }, - { daemon: testDaemon, preferences: createAgentPreferences } + { daemon: testDaemon, preferences: createAgentPreferences, seedNonce } ); }); diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index fff6214e2..d54ad5a2a 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -190,7 +190,8 @@ export const ensureHostSelected = async (page: Page) => { prefs.serverId = daemonId; localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs)); // Prevent the fixture's init-script from overwriting the corrected prefs on reload. - localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); return { ok: true } as const; }); diff --git a/packages/app/e2e/host-removal.spec.ts b/packages/app/e2e/host-removal.spec.ts index d5efcf8d1..4674c22d6 100644 --- a/packages/app/e2e/host-removal.spec.ts +++ b/packages/app/e2e/host-removal.spec.ts @@ -20,14 +20,24 @@ test('host removal removes the host from UI and persists after reload', async ({ updatedAt: nowIso, }; + const seededTestDaemon = { + id: 'e2e-test-daemon', + label: 'localhost', + endpoints: [`127.0.0.1:${daemonPort}`], + relay: null, + metadata: null, + createdAt: nowIso, + updatedAt: nowIso, + }; + + const seedOnceKey = `@paseo:e2e-host-removal-seeded:${Math.random().toString(36).slice(2)}`; + // Add a second host once (fixtures seed the primary host on every navigation). await page.addInitScript( - ({ daemon }) => { - const seedOnceKey = '@paseo:e2e-extra-daemon-seeded-once'; + ({ daemon, seededTestDaemon, seedOnceKey }) => { if (localStorage.getItem(seedOnceKey)) { return; } - localStorage.setItem(seedOnceKey, '1'); const raw = localStorage.getItem('@paseo:daemon-registry'); let parsed: any[] = []; @@ -38,10 +48,23 @@ test('host removal removes the host from UI and persists after reload', async ({ parsed = []; } } - const next = Array.isArray(parsed) ? [...parsed, daemon] : [daemon]; + const list = Array.isArray(parsed) ? parsed : []; + localStorage.setItem(seedOnceKey, '1'); + + const next = [...list]; + const hasSeeded = next.some((entry: any) => entry && entry.id === seededTestDaemon.id); + if (!hasSeeded) { + next.push(seededTestDaemon); + } + + const alreadyPresent = next.some((entry: any) => entry && entry.id === daemon.id); + if (!alreadyPresent) { + next.push(daemon); + } + localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next)); }, - { daemon: extraDaemon } + { daemon: extraDaemon, seededTestDaemon, seedOnceKey } ); await page.goto('/settings'); @@ -72,8 +95,10 @@ test('host removal removes the host from UI and persists after reload', async ({ ); // Prevent the fixture from overwriting storage on reload; verify persistence. - await page.evaluate(() => localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1')); + await page.evaluate(() => { + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); + }); await page.reload(); await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0); }); - diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts index 6ceddfd0c..97fb0c3db 100644 --- a/packages/app/e2e/host-selection.spec.ts +++ b/packages/app/e2e/host-selection.spec.ts @@ -42,7 +42,8 @@ test('new agent respects serverId in the URL', async ({ page }) => { await page.goto('/settings'); await page.evaluate( ({ daemon, preferences }) => { - localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences)); localStorage.removeItem('@paseo:settings'); diff --git a/packages/app/e2e/pairing-offer-parsing.spec.ts b/packages/app/e2e/pairing-offer-parsing.spec.ts index a5fc01814..994fd9114 100644 --- a/packages/app/e2e/pairing-offer-parsing.spec.ts +++ b/packages/app/e2e/pairing-offer-parsing.spec.ts @@ -18,7 +18,8 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo // Override the default fixture seeding for this test. await page.goto('/settings'); await page.evaluate(() => { - localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem('@paseo:daemon-registry', JSON.stringify([])); localStorage.removeItem('@paseo:settings'); }); @@ -35,6 +36,7 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo await page.getByText('+ Add Host', { exact: true }).click(); await page.getByText('Pair', { exact: true }).click(); + await page.getByText('Paste link', { exact: true }).click(); const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...'); await expect(input).toBeVisible(); diff --git a/packages/app/e2e/relay-fallback-connect.spec.ts b/packages/app/e2e/relay-fallback-connect.spec.ts index f1f239b0e..c2d3fec58 100644 --- a/packages/app/e2e/relay-fallback-connect.spec.ts +++ b/packages/app/e2e/relay-fallback-connect.spec.ts @@ -23,7 +23,8 @@ test('connects via relay when direct endpoints fail', async ({ page }) => { // Override the default fixture seeding for this test. await page.goto('/settings'); await page.evaluate((daemon) => { - localStorage.setItem('@paseo:e2e-disable-default-seed-once', '1'); + const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; + localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon])); localStorage.removeItem('@paseo:settings'); }, host); diff --git a/packages/app/package.json b/packages/app/package.json index 27a5dc466..944a25cf6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -42,6 +42,7 @@ "expo-audio": "~1.0.13", "expo-av": "^16.0.7", "expo-build-properties": "^1.0.9", + "expo-camera": "~17.0.10", "expo-clipboard": "~8.0.7", "expo-constants": "~18.0.9", "expo-crypto": "^15.0.8", diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 803ca4f5d..477716531 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -330,6 +330,7 @@ export default function RootLayout() { + diff --git a/packages/app/src/app/pair-scan.tsx b/packages/app/src/app/pair-scan.tsx new file mode 100644 index 000000000..294c4bb6c --- /dev/null +++ b/packages/app/src/app/pair-scan.tsx @@ -0,0 +1,250 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Alert, Platform, Pressable, Text, View } from "react-native"; +import { useRouter } from "expo-router"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { CameraView, useCameraPermissions } from "expo-camera"; +import type { BarcodeScanningResult } from "expo-camera"; +import { useDaemonRegistry } from "@/contexts/daemon-registry-context"; + +const styles = StyleSheet.create((theme) => ({ + container: { + flex: 1, + backgroundColor: theme.colors.surface0, + }, + header: { + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[6], + paddingBottom: theme.spacing[4], + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + headerTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + }, + headerButtonText: { + color: theme.colors.palette.blue[400], + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + }, + body: { + flex: 1, + paddingHorizontal: theme.spacing[6], + paddingBottom: theme.spacing[6], + }, + cameraWrap: { + flex: 1, + overflow: "hidden", + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface2, + }, + camera: { + flex: 1, + }, + overlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: "center", + alignItems: "center", + }, + scanFrame: { + width: 260, + height: 260, + }, + corner: { + position: "absolute", + width: 36, + height: 36, + borderColor: theme.colors.palette.blue[400], + }, + cornerTL: { + left: 0, + top: 0, + borderLeftWidth: 4, + borderTopWidth: 4, + borderTopLeftRadius: 12, + }, + cornerTR: { + right: 0, + top: 0, + borderRightWidth: 4, + borderTopWidth: 4, + borderTopRightRadius: 12, + }, + cornerBL: { + left: 0, + bottom: 0, + borderLeftWidth: 4, + borderBottomWidth: 4, + borderBottomLeftRadius: 12, + }, + cornerBR: { + right: 0, + bottom: 0, + borderRightWidth: 4, + borderBottomWidth: 4, + borderBottomRightRadius: 12, + }, + helperText: { + marginTop: theme.spacing[6], + color: theme.colors.foregroundMuted, + textAlign: "center", + fontSize: theme.fontSize.base, + }, + permissionCard: { + marginTop: theme.spacing[6], + padding: theme.spacing[6], + borderRadius: theme.borderRadius.xl, + backgroundColor: theme.colors.surface2, + gap: theme.spacing[4], + }, + permissionTitle: { + color: theme.colors.foreground, + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + }, + permissionBody: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.base, + }, + permissionButton: { + alignSelf: "flex-start", + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[3], + borderRadius: theme.borderRadius.lg, + backgroundColor: theme.colors.palette.blue[500], + }, + permissionButtonText: { + color: theme.colors.palette.white, + fontWeight: theme.fontWeight.semibold, + }, +})); + +function extractOfferUrlFromScan(result: BarcodeScanningResult): string | null { + const raw = typeof result.data === "string" ? result.data.trim() : ""; + if (!raw) return null; + + if (raw.includes("#offer=")) return raw; + + return null; +} + +export default function PairScanScreen() { + const { theme } = useUnistyles(); + const router = useRouter(); + const { upsertDaemonFromOfferUrl } = useDaemonRegistry(); + + const [permission, requestPermission] = useCameraPermissions(); + const [isPairing, setIsPairing] = useState(false); + const lastScannedRef = useRef(null); + + useEffect(() => { + if (Platform.OS === "web") return; + if (permission && permission.granted) return; + void requestPermission().catch(() => undefined); + }, [permission, requestPermission]); + + const handleScan = useCallback( + async (result: BarcodeScanningResult) => { + if (isPairing) return; + const offerUrl = extractOfferUrlFromScan(result); + if (!offerUrl) return; + + if (lastScannedRef.current === offerUrl) return; + lastScannedRef.current = offerUrl; + + try { + setIsPairing(true); + await upsertDaemonFromOfferUrl(offerUrl); + router.replace("/settings"); + } catch (error) { + lastScannedRef.current = null; + const message = error instanceof Error ? error.message : "Unable to pair host"; + Alert.alert("Error", message); + } finally { + setIsPairing(false); + } + }, + [isPairing, router, upsertDaemonFromOfferUrl] + ); + + if (Platform.OS === "web") { + return ( + + + Scan QR + router.back()}> + Close + + + + + Not available on web + + QR scanning isn’t supported in the web build. Use “Paste link” instead. + + router.replace("/settings")}> + Back to Settings + + + + + ); + } + + const granted = Boolean(permission?.granted); + + return ( + + + Scan QR + router.back()}> + Close + + + + + {!granted ? ( + + Camera permission + + Allow camera access to scan the pairing QR code from your daemon. + + void requestPermission()} + > + Grant permission + + + ) : ( + + + + + + + + + + + Point your camera at the pairing QR code. + + {isPairing ? ( + + Pairing… + + ) : null} + + + )} + + + ); +} diff --git a/packages/app/src/app/settings.tsx b/packages/app/src/app/settings.tsx index 413201655..bf2e7fc76 100644 --- a/packages/app/src/app/settings.tsx +++ b/packages/app/src/app/settings.tsx @@ -392,6 +392,7 @@ export default function SettingsScreen() { const { connectionStates, updateConnectionStatus } = useDaemonConnections(); const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false); const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose"); + const [pairSubMode, setPairSubMode] = useState<"choose" | "paste">("choose"); const [daemonForm, setDaemonForm] = useState<{ id: string | null; label: string; @@ -463,6 +464,7 @@ export default function SettingsScreen() { const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => { if (profile) { setDaemonFormMode("manual"); + setPairSubMode("choose"); setDaemonForm({ id: profile.id, label: profile.label, @@ -471,6 +473,7 @@ export default function SettingsScreen() { }); } else { setDaemonFormMode("choose"); + setPairSubMode("choose"); setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" }); } setIsDaemonFormVisible(true); @@ -479,6 +482,7 @@ export default function SettingsScreen() { const handleCloseDaemonForm = useCallback(() => { setIsDaemonFormVisible(false); setDaemonFormMode("choose"); + setPairSubMode("choose"); setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" }); }, []); @@ -521,6 +525,10 @@ export default function SettingsScreen() { } if (daemonFormMode === "pair") { + if (pairSubMode !== "paste") { + Alert.alert("Choose a method", "Select Scan QR or Paste link to pair."); + return; + } const offer = daemonForm.offerUrl.trim(); if (!offer) { Alert.alert("Offer required", "Paste the pairing link (…/#offer=...)"); @@ -541,7 +549,7 @@ export default function SettingsScreen() { } Alert.alert("Choose a method", "Select Pair or Manual to add a host."); - }, [daemonForm, daemonFormMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]); + }, [daemonForm, daemonFormMode, pairSubMode, addDaemon, updateDaemon, upsertDaemonFromOfferUrl, handleCloseDaemonForm]); const handleRemoveDaemon = useCallback( (profile: DaemonProfile) => { @@ -740,7 +748,10 @@ export default function SettingsScreen() { setDaemonFormMode("pair")} + onPress={() => { + setDaemonFormMode("pair"); + setPairSubMode("choose"); + }} > Pair @@ -771,17 +782,39 @@ export default function SettingsScreen() { {daemonFormMode === "pair" ? ( - Pairing Link - setDaemonForm((prev) => ({ ...prev, offerUrl: text }))} - placeholder="https://app.paseo.sh/#offer=..." - placeholderTextColor={defaultTheme.colors.mutedForeground} - autoCapitalize="none" - autoCorrect={false} - keyboardType="url" - /> + Pairing + + + { + handleCloseDaemonForm(); + router.push("/pair-scan"); + }} + > + Scan QR + + + setPairSubMode("paste")} + > + Paste link + + + + {pairSubMode === "paste" ? ( + setDaemonForm((prev) => ({ ...prev, offerUrl: text }))} + placeholder="https://app.paseo.sh/#offer=..." + placeholderTextColor={defaultTheme.colors.mutedForeground} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + /> + ) : null} ) : null} @@ -790,7 +823,13 @@ export default function SettingsScreen() { Cancel {!daemonForm.id && daemonFormMode !== "choose" ? ( - setDaemonFormMode("choose")}> + { + setDaemonFormMode("choose"); + setPairSubMode("choose"); + }} + > Back ) : null} diff --git a/packages/server/package.json b/packages/server/package.json index 9fb248dcb..28772ffb4 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -44,6 +44,7 @@ "pino": "^10.2.0", "pino-pretty": "^13.1.3", "playwright": "^1.56.1", + "qrcode": "^1.5.4", "tiny-invariant": "^1.3.3", "uuid": "^9.0.1", "ws": "^8.14.2", @@ -53,6 +54,7 @@ "@playwright/test": "^1.56.1", "@types/express": "^4.17.20", "@types/node": "^20.9.0", + "@types/qrcode": "^1.5.6", "@types/uuid": "^9.0.7", "@types/ws": "^8.5.8", "@vitest/ui": "^3.2.4", diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4b591a88b..cc006f38e 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -53,6 +53,7 @@ import { createConnectionOfferV1, encodeOfferToFragmentUrl, } from "./connection-offer.js"; +import { printPairingQrIfEnabled } from "./pairing-qr.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import type { AgentClient, @@ -420,6 +421,7 @@ export async function createPaseoDaemon( const url = encodeOfferToFragmentUrl({ offer, appBaseUrl }); logger.info({ url }, "pairing_offer"); + void printPairingQrIfEnabled({ url, logger }).catch(() => undefined); if (relayEnabled) { relayTransport?.stop().catch(() => undefined); diff --git a/packages/server/src/server/pairing-qr.ts b/packages/server/src/server/pairing-qr.ts new file mode 100644 index 000000000..3b4ab3399 --- /dev/null +++ b/packages/server/src/server/pairing-qr.ts @@ -0,0 +1,50 @@ +import * as QRCode from "qrcode"; +import type { QRCodeToStringOptionsTerminal, QRCodeToStringOptionsOther } from "qrcode"; +import type { Logger } from "pino"; + +function parseBooleanEnv(value: string | undefined): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "y", "on"].includes(normalized)) return true; + if (["0", "false", "no", "n", "off"].includes(normalized)) return false; + return undefined; +} + +function shouldPrintPairingQr(): boolean { + const env = parseBooleanEnv(process.env.PASEO_PAIRING_QR); + if (env !== undefined) return env; + return Boolean(process.stdout.isTTY); +} + +async function renderQr(url: string): Promise { + const terminalOptions: QRCodeToStringOptionsTerminal = { + type: "terminal", + small: true, + }; + + const utf8Options: QRCodeToStringOptionsOther = { + type: "utf8", + }; + + try { + return await QRCode.toString(url, terminalOptions); + } catch { + return await QRCode.toString(url, utf8Options); + } +} + +export async function printPairingQrIfEnabled(args: { + url: string; + logger?: Logger; +}): Promise { + if (!shouldPrintPairingQr()) return; + + const qr = await renderQr(args.url); + const out = `\nScan to pair:\n${qr}\n${args.url}\n`; + + try { + process.stdout.write(out); + } catch (error) { + args.logger?.debug({ error }, "Failed to print pairing QR"); + } +}