feat(pairing): QR offer + app scanner
This commit is contained in:
Generated
+219
@@ -8899,6 +8899,16 @@
|
|||||||
"form-data": "^4.0.4"
|
"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": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.14.0",
|
"version": "6.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
"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": {
|
"node_modules/decode-uri-component": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||||
@@ -11951,6 +11970,12 @@
|
|||||||
"node": ">=0.3.1"
|
"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": {
|
"node_modules/dir-glob": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
|
||||||
@@ -13769,6 +13794,26 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/expo-constants": {
|
||||||
"version": "18.0.13",
|
"version": "18.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz",
|
||||||
@@ -19590,6 +19635,23 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qrcode-terminal": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
|
||||||
@@ -19599,6 +19661,142 @@
|
|||||||
"qrcode-terminal": "bin/qrcode-terminal.js"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.0",
|
"version": "6.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||||
@@ -20528,6 +20726,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/requireg": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz",
|
||||||
@@ -21109,6 +21313,12 @@
|
|||||||
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"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"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"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-audio": "~1.0.13",
|
||||||
"expo-av": "^16.0.7",
|
"expo-av": "^16.0.7",
|
||||||
"expo-build-properties": "^1.0.9",
|
"expo-build-properties": "^1.0.9",
|
||||||
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.7",
|
"expo-clipboard": "~8.0.7",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
@@ -24979,6 +25196,7 @@
|
|||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
@@ -24988,6 +25206,7 @@
|
|||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/express": "^4.17.20",
|
"@types/express": "^4.17.20",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export default {
|
|||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
"android.permission.RECORD_AUDIO",
|
"android.permission.RECORD_AUDIO",
|
||||||
"android.permission.MODIFY_AUDIO_SETTINGS",
|
"android.permission.MODIFY_AUDIO_SETTINGS",
|
||||||
|
"CAMERA",
|
||||||
|
"android.permission.CAMERA",
|
||||||
],
|
],
|
||||||
// Base package - Gradle product flavors override this per variant
|
// Base package - Gradle product flavors override this per variant
|
||||||
package: "com.moboudra.paseo",
|
package: "com.moboudra.paseo",
|
||||||
@@ -52,6 +54,12 @@ export default {
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
"expo-router",
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
cameraPermission: "Allow $(PRODUCT_NAME) to access your camera to scan pairing QR codes.",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ test.beforeEach(async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const nowIso = new Date().toISOString();
|
const nowIso = new Date().toISOString();
|
||||||
|
const seedNonce = Math.random().toString(36).slice(2);
|
||||||
const testDaemon = {
|
const testDaemon = {
|
||||||
id: 'e2e-test-daemon',
|
id: 'e2e-test-daemon',
|
||||||
label: 'localhost',
|
label: 'localhost',
|
||||||
@@ -58,24 +59,28 @@ test.beforeEach(async ({ page }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon, preferences }) => {
|
({ daemon, preferences, seedNonce }) => {
|
||||||
// `addInitScript` runs on every navigation (including reloads). Some tests intentionally
|
// `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
|
// override storage and reload; they can opt out of seeding for the *next* navigation by
|
||||||
// setting this flag before the reload.
|
// setting this flag before the reload.
|
||||||
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
const disableOnceKey = '@paseo:e2e-disable-default-seed-once';
|
||||||
if (localStorage.getItem(disableOnceKey)) {
|
const disableValue = localStorage.getItem(disableOnceKey);
|
||||||
|
if (disableValue) {
|
||||||
localStorage.removeItem(disableOnceKey);
|
localStorage.removeItem(disableOnceKey);
|
||||||
return;
|
if (disableValue === seedNonce) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('@paseo:e2e', '1');
|
localStorage.setItem('@paseo:e2e', '1');
|
||||||
|
localStorage.setItem('@paseo:e2e-seed-nonce', seedNonce);
|
||||||
|
|
||||||
// Hard-reset anything that could point to a developer's real daemon.
|
// Hard-reset anything that could point to a developer's real daemon.
|
||||||
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem('@paseo:settings');
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||||
},
|
},
|
||||||
{ daemon: testDaemon, preferences: createAgentPreferences }
|
{ daemon: testDaemon, preferences: createAgentPreferences, seedNonce }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ export const ensureHostSelected = async (page: Page) => {
|
|||||||
prefs.serverId = daemonId;
|
prefs.serverId = daemonId;
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(prefs));
|
||||||
// Prevent the fixture's init-script from overwriting the corrected prefs on reload.
|
// 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;
|
return { ok: true } as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,14 +20,24 @@ test('host removal removes the host from UI and persists after reload', async ({
|
|||||||
updatedAt: nowIso,
|
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).
|
// Add a second host once (fixtures seed the primary host on every navigation).
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ daemon }) => {
|
({ daemon, seededTestDaemon, seedOnceKey }) => {
|
||||||
const seedOnceKey = '@paseo:e2e-extra-daemon-seeded-once';
|
|
||||||
if (localStorage.getItem(seedOnceKey)) {
|
if (localStorage.getItem(seedOnceKey)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
localStorage.setItem(seedOnceKey, '1');
|
|
||||||
|
|
||||||
const raw = localStorage.getItem('@paseo:daemon-registry');
|
const raw = localStorage.getItem('@paseo:daemon-registry');
|
||||||
let parsed: any[] = [];
|
let parsed: any[] = [];
|
||||||
@@ -38,10 +48,23 @@ test('host removal removes the host from UI and persists after reload', async ({
|
|||||||
parsed = [];
|
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));
|
localStorage.setItem('@paseo:daemon-registry', JSON.stringify(next));
|
||||||
},
|
},
|
||||||
{ daemon: extraDaemon }
|
{ daemon: extraDaemon, seededTestDaemon, seedOnceKey }
|
||||||
);
|
);
|
||||||
|
|
||||||
await page.goto('/settings');
|
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.
|
// 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 page.reload();
|
||||||
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
await expect(page.getByText(extraEndpoint, { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ test('new agent respects serverId in the URL', async ({ page }) => {
|
|||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ daemon, preferences }) => {
|
({ 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:daemon-registry', JSON.stringify([daemon]));
|
||||||
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
localStorage.setItem('@paseo:create-agent-preferences', JSON.stringify(preferences));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem('@paseo:settings');
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ test('pairing flow accepts #offer=ConnectionOfferV1 and stores sessionId + endpo
|
|||||||
// Override the default fixture seeding for this test.
|
// Override the default fixture seeding for this test.
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.evaluate(() => {
|
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.setItem('@paseo:daemon-registry', JSON.stringify([]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
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('+ Add Host', { exact: true }).click();
|
||||||
await page.getByText('Pair', { 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=...');
|
const input = page.getByPlaceholder('https://app.paseo.sh/#offer=...');
|
||||||
await expect(input).toBeVisible();
|
await expect(input).toBeVisible();
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ test('connects via relay when direct endpoints fail', async ({ page }) => {
|
|||||||
// Override the default fixture seeding for this test.
|
// Override the default fixture seeding for this test.
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
await page.evaluate((daemon) => {
|
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.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
|
||||||
localStorage.removeItem('@paseo:settings');
|
localStorage.removeItem('@paseo:settings');
|
||||||
}, host);
|
}, host);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"expo-audio": "~1.0.13",
|
"expo-audio": "~1.0.13",
|
||||||
"expo-av": "^16.0.7",
|
"expo-av": "^16.0.7",
|
||||||
"expo-build-properties": "^1.0.9",
|
"expo-build-properties": "^1.0.9",
|
||||||
|
"expo-camera": "~17.0.10",
|
||||||
"expo-clipboard": "~8.0.7",
|
"expo-clipboard": "~8.0.7",
|
||||||
"expo-constants": "~18.0.9",
|
"expo-constants": "~18.0.9",
|
||||||
"expo-crypto": "^15.0.8",
|
"expo-crypto": "^15.0.8",
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export default function RootLayout() {
|
|||||||
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
<Stack.Screen name="agent/[serverId]/[agentId]" options={{ gestureEnabled: false }} />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="audio-test" />
|
<Stack.Screen name="audio-test" />
|
||||||
|
<Stack.Screen name="pair-scan" />
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppWithSidebar>
|
</AppWithSidebar>
|
||||||
</HorizontalScrollProvider>
|
</HorizontalScrollProvider>
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={styles.headerButtonText}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
<View style={styles.body}>
|
||||||
|
<View style={styles.permissionCard}>
|
||||||
|
<Text style={styles.permissionTitle}>Not available on web</Text>
|
||||||
|
<Text style={styles.permissionBody}>
|
||||||
|
QR scanning isn’t supported in the web build. Use “Paste link” instead.
|
||||||
|
</Text>
|
||||||
|
<Pressable style={styles.permissionButton} onPress={() => router.replace("/settings")}>
|
||||||
|
<Text style={styles.permissionButtonText}>Back to Settings</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const granted = Boolean(permission?.granted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Scan QR</Text>
|
||||||
|
<Pressable onPress={() => router.back()}>
|
||||||
|
<Text style={styles.headerButtonText}>Close</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.body}>
|
||||||
|
{!granted ? (
|
||||||
|
<View style={styles.permissionCard}>
|
||||||
|
<Text style={styles.permissionTitle}>Camera permission</Text>
|
||||||
|
<Text style={styles.permissionBody}>
|
||||||
|
Allow camera access to scan the pairing QR code from your daemon.
|
||||||
|
</Text>
|
||||||
|
<Pressable
|
||||||
|
style={styles.permissionButton}
|
||||||
|
onPress={() => void requestPermission()}
|
||||||
|
>
|
||||||
|
<Text style={styles.permissionButtonText}>Grant permission</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.cameraWrap}>
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
facing="back"
|
||||||
|
barcodeScannerSettings={{ barcodeTypes: ["qr"] }}
|
||||||
|
onBarcodeScanned={handleScan}
|
||||||
|
/>
|
||||||
|
<View style={styles.overlay} pointerEvents="none">
|
||||||
|
<View style={styles.scanFrame}>
|
||||||
|
<View style={[styles.corner, styles.cornerTL]} />
|
||||||
|
<View style={[styles.corner, styles.cornerTR]} />
|
||||||
|
<View style={[styles.corner, styles.cornerBL]} />
|
||||||
|
<View style={[styles.corner, styles.cornerBR]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Point your camera at the pairing QR code.
|
||||||
|
</Text>
|
||||||
|
{isPairing ? (
|
||||||
|
<Text style={[styles.helperText, { color: theme.colors.foreground }]}>
|
||||||
|
Pairing…
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -392,6 +392,7 @@ export default function SettingsScreen() {
|
|||||||
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
const { connectionStates, updateConnectionStatus } = useDaemonConnections();
|
||||||
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
const [isDaemonFormVisible, setIsDaemonFormVisible] = useState(false);
|
||||||
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
|
const [daemonFormMode, setDaemonFormMode] = useState<"choose" | "manual" | "pair">("choose");
|
||||||
|
const [pairSubMode, setPairSubMode] = useState<"choose" | "paste">("choose");
|
||||||
const [daemonForm, setDaemonForm] = useState<{
|
const [daemonForm, setDaemonForm] = useState<{
|
||||||
id: string | null;
|
id: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -463,6 +464,7 @@ export default function SettingsScreen() {
|
|||||||
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
const handleOpenDaemonForm = useCallback((profile?: DaemonProfile) => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
setDaemonFormMode("manual");
|
setDaemonFormMode("manual");
|
||||||
|
setPairSubMode("choose");
|
||||||
setDaemonForm({
|
setDaemonForm({
|
||||||
id: profile.id,
|
id: profile.id,
|
||||||
label: profile.label,
|
label: profile.label,
|
||||||
@@ -471,6 +473,7 @@ export default function SettingsScreen() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setDaemonFormMode("choose");
|
setDaemonFormMode("choose");
|
||||||
|
setPairSubMode("choose");
|
||||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
||||||
}
|
}
|
||||||
setIsDaemonFormVisible(true);
|
setIsDaemonFormVisible(true);
|
||||||
@@ -479,6 +482,7 @@ export default function SettingsScreen() {
|
|||||||
const handleCloseDaemonForm = useCallback(() => {
|
const handleCloseDaemonForm = useCallback(() => {
|
||||||
setIsDaemonFormVisible(false);
|
setIsDaemonFormVisible(false);
|
||||||
setDaemonFormMode("choose");
|
setDaemonFormMode("choose");
|
||||||
|
setPairSubMode("choose");
|
||||||
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
setDaemonForm({ id: null, label: "", endpoint: "", offerUrl: "" });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -521,6 +525,10 @@ export default function SettingsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (daemonFormMode === "pair") {
|
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();
|
const offer = daemonForm.offerUrl.trim();
|
||||||
if (!offer) {
|
if (!offer) {
|
||||||
Alert.alert("Offer required", "Paste the pairing link (…/#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.");
|
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(
|
const handleRemoveDaemon = useCallback(
|
||||||
(profile: DaemonProfile) => {
|
(profile: DaemonProfile) => {
|
||||||
@@ -740,7 +748,10 @@ export default function SettingsScreen() {
|
|||||||
<View style={styles.formActionsRow}>
|
<View style={styles.formActionsRow}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={[styles.formButton, styles.formButtonPrimary]}
|
style={[styles.formButton, styles.formButtonPrimary]}
|
||||||
onPress={() => setDaemonFormMode("pair")}
|
onPress={() => {
|
||||||
|
setDaemonFormMode("pair");
|
||||||
|
setPairSubMode("choose");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
|
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Pair</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -771,17 +782,39 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
{daemonFormMode === "pair" ? (
|
{daemonFormMode === "pair" ? (
|
||||||
<View style={styles.formField}>
|
<View style={styles.formField}>
|
||||||
<Text style={styles.label}>Pairing Link</Text>
|
<Text style={styles.label}>Pairing</Text>
|
||||||
<TextInput
|
|
||||||
style={[styles.input, styles.inputUrl]}
|
<View style={styles.formActionsRow}>
|
||||||
value={daemonForm.offerUrl}
|
<Pressable
|
||||||
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
|
style={[styles.formButton, styles.formButtonPrimary]}
|
||||||
placeholder="https://app.paseo.sh/#offer=..."
|
onPress={() => {
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
handleCloseDaemonForm();
|
||||||
autoCapitalize="none"
|
router.push("/pair-scan");
|
||||||
autoCorrect={false}
|
}}
|
||||||
keyboardType="url"
|
>
|
||||||
/>
|
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Scan QR</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={[styles.formButton, styles.formButtonPrimary]}
|
||||||
|
onPress={() => setPairSubMode("paste")}
|
||||||
|
>
|
||||||
|
<Text style={[styles.formButtonText, styles.formButtonPrimaryText]}>Paste link</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{pairSubMode === "paste" ? (
|
||||||
|
<TextInput
|
||||||
|
style={[styles.input, styles.inputUrl, { marginTop: 12 }]}
|
||||||
|
value={daemonForm.offerUrl}
|
||||||
|
onChangeText={(text) => setDaemonForm((prev) => ({ ...prev, offerUrl: text }))}
|
||||||
|
placeholder="https://app.paseo.sh/#offer=..."
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="url"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -790,7 +823,13 @@ export default function SettingsScreen() {
|
|||||||
<Text style={styles.formButtonText}>Cancel</Text>
|
<Text style={styles.formButtonText}>Cancel</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{!daemonForm.id && daemonFormMode !== "choose" ? (
|
{!daemonForm.id && daemonFormMode !== "choose" ? (
|
||||||
<Pressable style={styles.formButton} onPress={() => setDaemonFormMode("choose")}>
|
<Pressable
|
||||||
|
style={styles.formButton}
|
||||||
|
onPress={() => {
|
||||||
|
setDaemonFormMode("choose");
|
||||||
|
setPairSubMode("choose");
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text style={styles.formButtonText}>Back</Text>
|
<Text style={styles.formButtonText}>Back</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"pino": "^10.2.0",
|
"pino": "^10.2.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"playwright": "^1.56.1",
|
"playwright": "^1.56.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"tiny-invariant": "^1.3.3",
|
"tiny-invariant": "^1.3.3",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"ws": "^8.14.2",
|
"ws": "^8.14.2",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"@playwright/test": "^1.56.1",
|
"@playwright/test": "^1.56.1",
|
||||||
"@types/express": "^4.17.20",
|
"@types/express": "^4.17.20",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/uuid": "^9.0.7",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/ws": "^8.5.8",
|
"@types/ws": "^8.5.8",
|
||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
createConnectionOfferV1,
|
createConnectionOfferV1,
|
||||||
encodeOfferToFragmentUrl,
|
encodeOfferToFragmentUrl,
|
||||||
} from "./connection-offer.js";
|
} from "./connection-offer.js";
|
||||||
|
import { printPairingQrIfEnabled } from "./pairing-qr.js";
|
||||||
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
@@ -420,6 +421,7 @@ export async function createPaseoDaemon(
|
|||||||
|
|
||||||
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
const url = encodeOfferToFragmentUrl({ offer, appBaseUrl });
|
||||||
logger.info({ url }, "pairing_offer");
|
logger.info({ url }, "pairing_offer");
|
||||||
|
void printPairingQrIfEnabled({ url, logger }).catch(() => undefined);
|
||||||
|
|
||||||
if (relayEnabled) {
|
if (relayEnabled) {
|
||||||
relayTransport?.stop().catch(() => undefined);
|
relayTransport?.stop().catch(() => undefined);
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user