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