mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
Revert "feat(mobile): verified https pairing links; custom scheme carries no secret (#27475)" (#27693)
This reverts commit 20f8cb02c1.
Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -104,8 +104,6 @@ jobs:
|
||||
- name: Expo prebuild
|
||||
env:
|
||||
ORCA_IOS_APS_ENVIRONMENT: production
|
||||
# Set the repo variable to 1 only after the App ID has Associated Domains enabled.
|
||||
ORCA_IOS_ASSOCIATED_DOMAINS: ${{ vars.ORCA_IOS_ASSOCIATED_DOMAINS }}
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
- name: Install CocoaPods
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Verified pairing links
|
||||
|
||||
Orca Mobile pairs from `https://www.onorca.dev/pair#<code>`. The pairing code is in the URL
|
||||
fragment, which browsers never send to a server. iOS and Android open that link in Orca Mobile only
|
||||
after they have fetched and verified the two files in this directory from `www.onorca.dev`. Links
|
||||
that use the `orca://` scheme no longer carry a code into the app, because any app can claim that
|
||||
scheme.
|
||||
|
||||
The marketing site that serves `www.onorca.dev` is not in this repository, so these files must be
|
||||
deployed there by hand.
|
||||
|
||||
## What to deploy
|
||||
|
||||
| File in this directory | Serve at | Content-Type |
|
||||
| ---------------------------- | --------------------------------------------------------------- | ------------------ |
|
||||
| `apple-app-site-association` | `https://www.onorca.dev/.well-known/apple-app-site-association` | `application/json` |
|
||||
| `assetlinks.json` | `https://www.onorca.dev/.well-known/assetlinks.json` | `application/json` |
|
||||
| `pair.html` | `https://www.onorca.dev/pair` | `text/html` |
|
||||
|
||||
Both `.well-known` files must:
|
||||
|
||||
- return `200` directly, with **no redirect**. The apex `onorca.dev` redirects to `www`, which is
|
||||
why the links use `www.onorca.dev`. Neither OS follows a redirect while verifying.
|
||||
- need **no authentication**, cookie, bot challenge, or other interstitial. Exempt `/.well-known/`
|
||||
from any Cloudflare bot or challenge rule.
|
||||
- be served with `Content-Type: application/json`. The Apple file has no extension, so most hosts
|
||||
serve it as `application/octet-stream` unless a header rule overrides that.
|
||||
|
||||
`pair.html` is the page people see when the app is not installed, is too old to claim the link, or
|
||||
the OS has not verified the domain yet. It explains how to pair instead, and can copy the code from
|
||||
the fragment for the paste flow, which older app versions also accept. Do not add analytics or any
|
||||
third-party script to this page: a script on it could read the code from the fragment.
|
||||
|
||||
## Placeholders to fill in before deploying
|
||||
|
||||
- `apple-app-site-association`: replace `REPLACE_WITH_APPLE_TEAM_ID` with the Apple Developer Team
|
||||
ID (the `APPLE_TEAM_ID` secret used by `mobile/fastlane`).
|
||||
- `assetlinks.json`: replace `REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256` with the SHA-256 fingerprint
|
||||
of the certificate that signs the shipped APK. If the app is published on Google Play, use the
|
||||
**app signing key** fingerprint from Play Console → App integrity, not the upload key. You can list
|
||||
more than one fingerprint, for example both the Play key and a key used for direct APK downloads.
|
||||
|
||||
The direct-download APK built by `.github/workflows/mobile-android-release.yml` is currently signed
|
||||
with the build template's public debug keystore. Do not list that fingerprint (`FA:C6:17:45:…`): it
|
||||
is public, so any app signed with it could claim the links.
|
||||
`src/shared/verified-pairing-link-files.test.ts` fails if it appears.
|
||||
|
||||
## App side
|
||||
|
||||
- Android: `mobile/app.config.js` adds an `autoVerify` intent filter for `https://www.onorca.dev/pair`.
|
||||
Android checks `assetlinks.json` when the app is installed or updated.
|
||||
- iOS: the associated-domains entitlement is opt-in, because an App Store archive with it fails to
|
||||
sign until the App ID has the capability. To turn it on:
|
||||
1. In the Apple Developer portal, enable **Associated Domains** on the App ID
|
||||
`com.stably.orca.mobile`. The release lane regenerates the provisioning profile on every run.
|
||||
2. Set the GitHub repository variable `ORCA_IOS_ASSOCIATED_DOMAINS` to `1`. The iOS release
|
||||
workflow then builds with `applinks:www.onorca.dev`.
|
||||
|
||||
The host, the path, and the app identifiers are mirrored in `src/shared/verified-pairing-link.ts`,
|
||||
`mobile/app.config.js`, and the files in this directory. `src/shared/verified-pairing-link-files.test.ts`
|
||||
fails if they drift apart.
|
||||
|
||||
## Checking a deployment
|
||||
|
||||
```sh
|
||||
curl -sI https://www.onorca.dev/.well-known/apple-app-site-association # 200, application/json
|
||||
curl -sI https://www.onorca.dev/.well-known/assetlinks.json # 200, application/json
|
||||
```
|
||||
|
||||
Apple fetches the file through its own CDN, which can lag by up to a day:
|
||||
`https://app-site-association.cdn-apple.com/a/v1/www.onorca.dev`. On an Android device, run
|
||||
`adb shell pm get-app-links com.stably.orca.mobile` and look for `www.onorca.dev: verified`.
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"applinks": {
|
||||
"details": [
|
||||
{
|
||||
"appIDs": ["REPLACE_WITH_APPLE_TEAM_ID.com.stably.orca.mobile"],
|
||||
"components": [
|
||||
{
|
||||
"/": "/pair",
|
||||
"comment": "Orca Mobile pairing link. The pairing code is in the fragment."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.stably.orca.mobile",
|
||||
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,155 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="referrer" content="no-referrer" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<!-- The pairing code is in the URL fragment: never load third-party scripts on this page. -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self'"
|
||||
/>
|
||||
<title>Pair Orca Mobile</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #ffffff;
|
||||
--fg: #111111;
|
||||
--muted: #5c5c5c;
|
||||
--border: #e2e2e2;
|
||||
--button-bg: #111111;
|
||||
--button-fg: #ffffff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #111111;
|
||||
--fg: #f2f2f2;
|
||||
--muted: #a3a3a3;
|
||||
--border: #2c2c2c;
|
||||
--button-bg: #f2f2f2;
|
||||
--button-fg: #111111;
|
||||
}
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font:
|
||||
16px/1.5 -apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
main {
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
p,
|
||||
li {
|
||||
color: var(--muted);
|
||||
}
|
||||
ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
button {
|
||||
margin-top: 8px;
|
||||
padding: 10px 16px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: var(--button-bg);
|
||||
color: var(--button-fg);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--fg);
|
||||
font:
|
||||
12px ui-monospace,
|
||||
monospace;
|
||||
}
|
||||
.note {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
[hidden] {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Pair Orca Mobile</h1>
|
||||
<p>
|
||||
This link pairs your phone with Orca on your computer. It opens in the Orca app when Orca
|
||||
Mobile is installed and up to date.
|
||||
</p>
|
||||
<p><strong>Seeing this page instead?</strong></p>
|
||||
<ol>
|
||||
<li>Update Orca Mobile, then open the link again.</li>
|
||||
<li>
|
||||
Or open Orca Mobile, tap <strong>Pair</strong>, and scan the QR code shown in Orca on your
|
||||
computer under <strong>Settings → Mobile</strong>.
|
||||
</li>
|
||||
<li id="copy-step" hidden>
|
||||
Or copy the pairing code and paste it in Orca Mobile under
|
||||
<strong>Pair → Paste code</strong>.
|
||||
<br />
|
||||
<button id="copy-button" type="button">Copy pairing code</button>
|
||||
<input id="code-fallback" type="text" readonly hidden aria-label="Pairing code" />
|
||||
</li>
|
||||
</ol>
|
||||
<p>Don't have the app? <a href="/download">Download Orca Mobile</a>.</p>
|
||||
<p class="note">
|
||||
Treat the pairing code like a password. It is in the part of this link after “#”, which your
|
||||
browser keeps on this device and never sends to onorca.dev.
|
||||
</p>
|
||||
</main>
|
||||
<script>
|
||||
;(function () {
|
||||
var code = window.location.hash.slice(1)
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
var step = document.getElementById('copy-step')
|
||||
var button = document.getElementById('copy-button')
|
||||
var fallback = document.getElementById('code-fallback')
|
||||
step.hidden = false
|
||||
function showFallback() {
|
||||
fallback.value = code
|
||||
fallback.hidden = false
|
||||
fallback.select()
|
||||
}
|
||||
button.addEventListener('click', function () {
|
||||
if (!navigator.clipboard) {
|
||||
showFallback()
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(code).then(function () {
|
||||
button.textContent = 'Copied'
|
||||
}, showFallback)
|
||||
})
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,37 +11,12 @@
|
||||
const APS_ENVIRONMENT =
|
||||
process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
|
||||
|
||||
// Verified pairing links. Mirrors src/shared/verified-pairing-link.ts and config/verified-links/;
|
||||
// src/shared/verified-pairing-link-files.test.ts fails if they drift.
|
||||
const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev'
|
||||
const VERIFIED_PAIRING_LINK_PATH = '/pair'
|
||||
// Why opt-in: an App Store archive with this entitlement fails to sign until the App ID has the
|
||||
// Associated Domains capability (config/verified-links/README.md).
|
||||
const IOS_ASSOCIATED_DOMAINS = process.env.ORCA_IOS_ASSOCIATED_DOMAINS === '1'
|
||||
|
||||
module.exports = ({ config }) => ({
|
||||
...config,
|
||||
ios: {
|
||||
...config.ios,
|
||||
...(IOS_ASSOCIATED_DOMAINS
|
||||
? { associatedDomains: [`applinks:${VERIFIED_PAIRING_LINK_HOST}`] }
|
||||
: {}),
|
||||
entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT }
|
||||
},
|
||||
android: {
|
||||
...config.android,
|
||||
intentFilters: [
|
||||
...(config.android?.intentFilters ?? []),
|
||||
{
|
||||
action: 'VIEW',
|
||||
autoVerify: true,
|
||||
data: [
|
||||
{ scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH }
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT']
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: (config.plugins ?? []).map((plugin) =>
|
||||
plugin === 'expo-notifications'
|
||||
? [
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { redirectIncomingPairingLink } from '../src/transport/incoming-pairing-link'
|
||||
|
||||
// Why: rewrite before routing, so no OS-delivered custom-scheme link reaches a screen that reads a code.
|
||||
export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
|
||||
return redirectIncomingPairingLink(path) ?? path
|
||||
}
|
||||
+30
-1
@@ -4,10 +4,11 @@ import { readNativeNotificationData } from '../src/notifications/native-notifica
|
||||
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack, useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { startPushTokenSync } from '../src/notifications/push-registration'
|
||||
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
|
||||
|
||||
@@ -41,6 +43,7 @@ Notifications.setNotificationHandler({
|
||||
})
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
|
||||
useEffect(() => {
|
||||
@@ -67,6 +70,32 @@ export default function RootLayout() {
|
||||
useEffect(() => appUpdateChecker.start(), [])
|
||||
useEffect(() => startAndroidForegroundPushPresentation(), [])
|
||||
|
||||
// Why: route `orca://pair?...` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
// covers cold-start (link tapped while app was closed); the listener
|
||||
// covers warm-start (link tapped while app is in memory).
|
||||
useEffect(() => {
|
||||
function handleUrl(url: string) {
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (code) {
|
||||
// Why: Android camera launches can leave Expo Router's unmatched
|
||||
// `orca://pair` route underneath this screen; replacing keeps cancel
|
||||
// and edge-back from revealing the router error page.
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
}
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) {
|
||||
handleUrl(url)
|
||||
}
|
||||
})
|
||||
|
||||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// ─── Notification tap routing ───
|
||||
// Why: iOS delivers local notification taps through expo-notifications,
|
||||
// not Linking. Route both cold-start and warm-start responses to the host
|
||||
|
||||
@@ -3,10 +3,7 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } fro
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import {
|
||||
describePairingHost,
|
||||
resolvePairConfirmRouteState
|
||||
} from '../src/transport/pair-confirm-state'
|
||||
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
@@ -46,7 +43,6 @@ export default function PairConfirmScreen() {
|
||||
|
||||
const routeState = resolvePairConfirmRouteState(params.code)
|
||||
const offer = routeState.offer
|
||||
const host = offer ? describePairingHost(offer) : null
|
||||
const resolvedStatus =
|
||||
status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status
|
||||
const resolvedErrorMessage =
|
||||
@@ -156,24 +152,8 @@ export default function PairConfirmScreen() {
|
||||
<>
|
||||
<Text style={styles.title}>Pair with this desktop?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
Check that this address matches the one under the QR code in Orca on your computer.
|
||||
You opened a pairing link from your desktop. Confirm to add it to your hosts.
|
||||
</Text>
|
||||
{host && (
|
||||
<View style={styles.hostDetails}>
|
||||
<Text style={styles.detailLabel}>Address</Text>
|
||||
<Text style={styles.detailValue} selectable>
|
||||
{host.endpoint}
|
||||
</Text>
|
||||
{host.keyFingerprint && (
|
||||
<>
|
||||
<Text style={styles.detailLabel}>Host key</Text>
|
||||
<Text style={styles.detailValue} selectable>
|
||||
{host.keyFingerprint}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.actionStack}>
|
||||
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
|
||||
<Text style={styles.primaryButtonText}>Pair</Text>
|
||||
@@ -254,27 +234,6 @@ const styles = StyleSheet.create({
|
||||
maxWidth: 520,
|
||||
alignSelf: 'center'
|
||||
},
|
||||
hostDetails: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
detailLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
detailValue: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.bodySize,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
actionStack: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
|
||||
@@ -9,10 +9,9 @@ import {
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import { PAIR_SCAN_LINK_REFUSED_NOTICE } from '../src/transport/incoming-pairing-link'
|
||||
import {
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
@@ -35,18 +34,6 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
const SCAN_RETICLE_SCALE = 0.62
|
||||
const SCAN_RETICLE_MAX_SIZE = 360
|
||||
|
||||
function LinkRefusedNotice({ notice }: { notice: string | undefined }) {
|
||||
if (notice !== PAIR_SCAN_LINK_REFUSED_NOTICE) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Text style={styles.notice}>
|
||||
That orca:// link can’t pair your phone, because any app can open orca:// links. Scan the QR
|
||||
code on your computer instead.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
function Step({ number, text }: { number: number; text: string }) {
|
||||
return (
|
||||
<View style={styles.step}>
|
||||
@@ -62,7 +49,6 @@ export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const refreshHostClient = useRefreshHostClient()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { notice } = useLocalSearchParams<{ notice?: string }>()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
@@ -74,15 +60,6 @@ export default function PairScanScreen() {
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
// Why: a refused link can cold-start the app on this screen, with nothing to go back to.
|
||||
const goBack = useCallback(() => {
|
||||
if (router.canGoBack()) {
|
||||
router.back()
|
||||
} else {
|
||||
router.replace('/')
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const setPairScanRootRef = useCallback((node: View | null): void => {
|
||||
if (node !== null) {
|
||||
mountedRef.current = true
|
||||
@@ -241,10 +218,9 @@ export default function PairScanScreen() {
|
||||
const canAskAgain = permission.canAskAgain !== false
|
||||
return (
|
||||
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={goBack}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
<LinkRefusedNotice notice={notice} />
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.title}>
|
||||
{canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'}
|
||||
@@ -275,7 +251,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="Paste the pairing link or code"
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
@@ -285,11 +261,10 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={goBack}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
|
||||
<LinkRefusedNotice notice={notice} />
|
||||
<View style={styles.steps}>
|
||||
<Step number={1} text="Open Orca on your computer" />
|
||||
<Step number={2} text="Go to Settings → Mobile" />
|
||||
@@ -374,7 +349,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="Paste the pairing link or code"
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
|
||||
+84
-3
@@ -1,6 +1,87 @@
|
||||
import { Redirect } from 'expo-router'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
|
||||
// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code.
|
||||
export default function PairRedirectScreen() {
|
||||
return <Redirect href="/pair-scan" />
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [missingCode, setMissingCode] = useState(false)
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
router.replace('/')
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
async function redirectToConfirm() {
|
||||
const codeParam = Array.isArray(params.code) ? params.code[0] : params.code
|
||||
if (codeParam) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code: codeParam } })
|
||||
return
|
||||
}
|
||||
|
||||
const initialUrl = await Linking.getInitialURL().catch(() => null)
|
||||
const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (code) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
return
|
||||
}
|
||||
setMissingCode(true)
|
||||
}
|
||||
|
||||
void redirectToConfirm()
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [params.code, router])
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{missingCode ? (
|
||||
<>
|
||||
<Text style={styles.errorText}>Missing pairing code</Text>
|
||||
<Pressable style={styles.primaryButton} onPress={goHome}>
|
||||
<Text style={styles.primaryButtonText}>Back to home</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<ActivityIndicator size="large" color={colors.textSecondary} />
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgBase,
|
||||
padding: spacing.lg
|
||||
},
|
||||
errorText: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center'
|
||||
},
|
||||
primaryButton: {
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.textPrimary,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.sm + 2
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,17 +15,6 @@ export const pairScanStyles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
notice: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.card,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
|
||||
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { redirectIncomingPairingLink } from './incoming-pairing-link'
|
||||
|
||||
// The oracle is the router itself: what its linking reads from an OS link after the hook passes it on.
|
||||
function routerParams(url: string): URLSearchParams | null {
|
||||
try {
|
||||
return parseUrlUsingCustomBase(extractExpoPathFromURL([], url)).searchParams
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TARGETS = [
|
||||
'orca://pair-confirm?code=SECRET',
|
||||
'orca://pair?code=SECRET',
|
||||
'orca://pair-confirm?a=1&code=SECRET',
|
||||
'orca:///x/../pair-confirm?code=SECRET',
|
||||
'orca://(group)/pair-confirm?code=SECRET',
|
||||
'orca://h/host-1/session/wt-1?code=SECRET',
|
||||
'https://evil.example/pair-confirm?code=SECRET',
|
||||
'exp+orca-mobile://pair-confirm?code=SECRET'
|
||||
]
|
||||
// Tab, LF and CR are removed anywhere; C0 controls and space are trimmed from the ends.
|
||||
const STRIPPED = ['\t', '\n', '\r']
|
||||
const TRIMMED = ['\u0000', '\u001f', ' ']
|
||||
|
||||
function* mutations(url: string): Generator<string> {
|
||||
yield url
|
||||
yield url.toUpperCase()
|
||||
yield url.replace('code', 'CODE')
|
||||
yield url.replace('code', '%63ode')
|
||||
yield url.replace('code', 'code')
|
||||
yield url.replace('pair-confirm', 'pair%2Dconfirm')
|
||||
yield url.replace('?code=', '?code%3D')
|
||||
yield url.replace('&code=', '%26code=')
|
||||
for (const character of STRIPPED) {
|
||||
for (let index = 0; index <= url.length; index++) {
|
||||
yield url.slice(0, index) + character + url.slice(index)
|
||||
}
|
||||
yield url.replace('code', `co${encodeURIComponent(character)}de`)
|
||||
}
|
||||
for (const character of TRIMMED) {
|
||||
yield `${character}${url}${character}`
|
||||
}
|
||||
}
|
||||
|
||||
function* wrapped(url: string): Generator<string> {
|
||||
for (const depth of [0, 1, 2, 7]) {
|
||||
for (const layers of [0, 1, 2, 3]) {
|
||||
let current = url
|
||||
for (let level = 0; level < depth; level++) {
|
||||
let inner = current
|
||||
for (let layer = 0; layer < layers; layer++) {
|
||||
inner = encodeURIComponent(inner)
|
||||
}
|
||||
current = `orca://expo-development-client/?url=${inner}`
|
||||
}
|
||||
yield current
|
||||
if (depth === 0) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* corpus(): Generator<string> {
|
||||
for (const target of TARGETS) {
|
||||
for (const mutated of mutations(target)) {
|
||||
yield* wrapped(mutated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('redirectIncomingPairingLink agrees with the router', () => {
|
||||
it('never passes on a link from which the router would read a code', () => {
|
||||
let checked = 0
|
||||
for (const url of corpus()) {
|
||||
for (const allowDevClientLinks of [true, false]) {
|
||||
if (redirectIncomingPairingLink(url, allowDevClientLinks) === null) {
|
||||
const params = routerParams(url)
|
||||
expect(params, url).not.toBeNull()
|
||||
expect(params?.has('code'), JSON.stringify(url)).toBe(false)
|
||||
}
|
||||
}
|
||||
checked++
|
||||
}
|
||||
expect(checked).toBeGreaterThan(1000)
|
||||
}, 60_000)
|
||||
|
||||
it('refuses every development-client wrapper in a release build', () => {
|
||||
for (const url of corpus()) {
|
||||
if (url.toLowerCase().includes('orca://expo-development-client')) {
|
||||
expect(redirectIncomingPairingLink(url, false), JSON.stringify(url)).not.toBeNull()
|
||||
}
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it('still lets ordinary links and the dev launcher through', () => {
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1', false)).toBeNull()
|
||||
expect(
|
||||
redirectIncomingPairingLink(
|
||||
`orca://expo-development-client/?url=${encodeURIComponent('http://192.168.1.5:8081')}`,
|
||||
true
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,77 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAIR_SCAN_LINK_REFUSED_NOTICE, redirectIncomingPairingLink } from './incoming-pairing-link'
|
||||
|
||||
const refused = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
|
||||
|
||||
describe('redirectIncomingPairingLink', () => {
|
||||
it('sends a verified https link to the confirm screen with the fragment code', () => {
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair#abc_1-2')).toBe(
|
||||
'/pair-confirm?code=abc_1-2'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a pairing code carried by the custom scheme', () => {
|
||||
for (const url of [
|
||||
'orca://pair?code=abc123',
|
||||
'orca://pair#abc123',
|
||||
'ORCA://PAIR?code=abc123',
|
||||
'orca://pair/?code=abc123',
|
||||
'orca:///pair?code=abc123',
|
||||
'orca://pair-confirm?code=abc123',
|
||||
'orca:///pair-confirm?code=abc123',
|
||||
'exp+orca-mobile://pair-confirm?code=abc123',
|
||||
'orca://pair%2Dconfirm?code=abc123',
|
||||
'orca://./pair-confirm?code=abc123',
|
||||
'orca://h/host-1?code=abc123',
|
||||
'orca://pair-confirm?%63ode=abc123',
|
||||
'orca://pair-confirm?CODE=abc123',
|
||||
'orca:///ignored/../pair-confirm?code=abc123',
|
||||
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?code=abc123')}`,
|
||||
`exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(
|
||||
encodeURIComponent('orca://pair-confirm?code=abc123')
|
||||
)}`,
|
||||
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?%2563ode=abc')}`,
|
||||
`orca://expo-development-client/?junk=%ZZ&url=${encodeURIComponent('orca://pair-confirm?code=x')}`,
|
||||
'orca://expo-development-client/?url=orca%3A%2F%2Fpair-confirm%3Fa%3D%E0%26code%3DSECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?a%3D%E0%26code%3DSECRET',
|
||||
'orca://pair-confirm?code=SECRET#\u2029x',
|
||||
'orca://pair-confirm?code=SECRET#\nx',
|
||||
'orca://pair-confirm?code=SECRET#\u2028x',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?co%09de=SECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?c%0Aode=SECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm%3Fcode%0D=SECRET'
|
||||
]) {
|
||||
expect(redirectIncomingPairingLink(url), url).toBe(refused)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a code nested in development-client wrappers at any depth', () => {
|
||||
let url = 'orca://pair-confirm?code=abc123'
|
||||
for (let depth = 0; depth < 12; depth++) {
|
||||
url = `orca://expo-development-client/?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
expect(redirectIncomingPairingLink(url)).toBe(refused)
|
||||
})
|
||||
|
||||
it('opens the scanner for a bare custom-scheme pairing link', () => {
|
||||
expect(redirectIncomingPairingLink('orca://pair')).toBe('/pair-scan')
|
||||
expect(redirectIncomingPairingLink('orca://pair/')).toBe('/pair-scan')
|
||||
})
|
||||
|
||||
it('refuses unverified https links that target a pairing screen', () => {
|
||||
expect(redirectIncomingPairingLink('https://evil.example/pair#abc123')).toBe(refused)
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair-confirm?code=abc')).toBe(
|
||||
refused
|
||||
)
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair?code=abc')).toBe(refused)
|
||||
})
|
||||
|
||||
it('leaves every other link to the router', () => {
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://pairing')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1?notice=x#y')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://pair-scan')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1/session/pair')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('/settings')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,80 +0,0 @@
|
||||
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
|
||||
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
|
||||
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
|
||||
|
||||
export const PAIR_SCAN_LINK_REFUSED_NOTICE = 'custom-scheme-link'
|
||||
|
||||
const REFUSED_PATH = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
|
||||
const PAIRING_ROUTES = new Set(['pair', 'pair-confirm'])
|
||||
const DEV_CLIENT_HOST = 'expo-development-client'
|
||||
|
||||
/** The router's own reading of an OS link: the same functions its linking uses, after this hook. */
|
||||
export function resolveRouterTarget(url: string): {
|
||||
pathname: string
|
||||
searchParams: URLSearchParams
|
||||
hash: string
|
||||
} {
|
||||
const parsed = parseUrlUsingCustomBase(extractExpoPathFromURL([], url))
|
||||
return { pathname: parsed.pathname, searchParams: parsed.searchParams, hash: parsed.hash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites an OS-delivered link before the router sees it. Null leaves the link to the router.
|
||||
* Why: the OS hands an `orca://` URL to whichever app claimed the scheme, so a code it carries may
|
||||
* already be in another app's hands. Only a verified https link may start pairing. The decision is
|
||||
* made on the router's own parse, never a re-implementation, and anything unparseable is refused.
|
||||
*/
|
||||
export function redirectIncomingPairingLink(
|
||||
url: string,
|
||||
allowDevClientLinks: boolean = typeof __DEV__ !== 'undefined' && __DEV__
|
||||
): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return `/pair-confirm?code=${encodeURIComponent(verifiedCode)}`
|
||||
}
|
||||
// Why: the router unwraps `expo-development-client/?url=` links; release builds have no use for them.
|
||||
const outer = parseOuterUrl(url)
|
||||
if (!allowDevClientLinks && outer?.hostname === DEV_CLIENT_HOST) {
|
||||
return REFUSED_PATH
|
||||
}
|
||||
let target: ReturnType<typeof resolveRouterTarget>
|
||||
try {
|
||||
target = resolveRouterTarget(url)
|
||||
} catch {
|
||||
return REFUSED_PATH
|
||||
}
|
||||
if ([...target.searchParams.keys()].some((key) => key.toLowerCase() === 'code')) {
|
||||
return REFUSED_PATH
|
||||
}
|
||||
// Pairing screens are top-level routes; groups like `(tabs)` don't count as segments.
|
||||
const route = target.pathname
|
||||
.split('/')
|
||||
.map((segment) => safeDecode(segment).toLowerCase())
|
||||
.find((segment) => segment !== '' && !/^\(.*\)$/.test(segment))
|
||||
if (!route || !PAIRING_ROUTES.has(route)) {
|
||||
return null
|
||||
}
|
||||
// Why the outer hash too: the router drops it, but a legacy `orca://pair#<code>` still earns the notice.
|
||||
const carriesPayload =
|
||||
[...target.searchParams.keys()].length > 0 ||
|
||||
target.hash.length > 1 ||
|
||||
(outer?.hash.length ?? 0) > 1
|
||||
return carriesPayload ? REFUSED_PATH : '/pair-scan'
|
||||
}
|
||||
|
||||
// Same check the router applies before unwrapping a development-client link.
|
||||
function parseOuterUrl(url: string): URL | null {
|
||||
try {
|
||||
return new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state'
|
||||
import { resolvePairConfirmRouteState } from './pair-confirm-state'
|
||||
import type { PairingOffer } from './types'
|
||||
|
||||
function encodeOffer(offer: PairingOffer): string {
|
||||
@@ -52,28 +52,3 @@ describe('resolvePairConfirmRouteState', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('describePairingHost', () => {
|
||||
const publicKeyB64 = Buffer.alloc(32, 7).toString('base64')
|
||||
|
||||
it('shows the endpoint and a host key fingerprint before pairing', () => {
|
||||
const host = describePairingHost({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token-abc',
|
||||
publicKeyB64
|
||||
})
|
||||
expect(host.endpoint).toBe('ws://192.168.1.10:6768')
|
||||
expect(host.keyFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/)
|
||||
})
|
||||
|
||||
it('omits the fingerprint for an unreadable key', () => {
|
||||
const host = describePairingHost({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token-abc',
|
||||
publicKeyB64: 'pubkey-xyz'
|
||||
})
|
||||
expect(host.keyFingerprint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { deriveHostFingerprint } from '../notifications/push-host-fingerprint'
|
||||
import { parsePairingCode } from './pairing'
|
||||
import type { PairingOffer } from './types'
|
||||
|
||||
@@ -18,11 +17,3 @@ export function resolvePairConfirmRouteState(code: string | undefined): PairConf
|
||||
|
||||
return { kind: 'ready', offer, errorMessage: '' }
|
||||
}
|
||||
|
||||
/** What the user checks against the desktop before pairing; a verified link proves the app, not the host. */
|
||||
export function describePairingHost(offer: PairingOffer): {
|
||||
endpoint: string
|
||||
keyFingerprint: string | null
|
||||
} {
|
||||
return { endpoint: offer.endpoint, keyFingerprint: deriveHostFingerprint(offer.publicKeyB64) }
|
||||
}
|
||||
|
||||
@@ -39,11 +39,6 @@ describe('pairing deep links', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code')
|
||||
})
|
||||
|
||||
it('extracts the code from a verified https pairing link fragment', () => {
|
||||
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair#abc123')).toBe('abc123')
|
||||
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair?code=abc123')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores empty and unrelated URLs', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair')).toBeNull()
|
||||
expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull()
|
||||
@@ -68,13 +63,6 @@ describe('pairing deep links', () => {
|
||||
expect(parsePairingCode(code)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('parses a pasted or scanned verified https pairing link', () => {
|
||||
const link = `https://www.onorca.dev/pair#${encodeOffer()}`
|
||||
|
||||
expect(parsePairingCode(link)).toEqual(offer)
|
||||
expect(decodePairingUrl(link)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => {
|
||||
const proxiedOffer = {
|
||||
...offer,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
|
||||
import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
|
||||
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
|
||||
@@ -18,14 +17,10 @@ export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: in-app QR scan and paste never pass through the OS link dispatcher, so
|
||||
// they keep accepting the custom scheme that old desktops emit. OS-delivered
|
||||
// links go through incoming-pairing-link.ts, which refuses custom-scheme codes.
|
||||
// Why: system camera apps hand us the raw custom-scheme URL. Keeping
|
||||
// extraction here makes QR scan, paste, and external deep-link flows
|
||||
// accept the same URL shapes.
|
||||
export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return verifiedCode
|
||||
}
|
||||
const trimmed = url.trim()
|
||||
const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed)
|
||||
if (!match) {
|
||||
@@ -54,8 +49,8 @@ export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: accept an `orca://pair?...` URL, a verified https link, or the bare
|
||||
// base64 string so the paste-pair flow can take whichever the user actually
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// string so the paste-pair flow can take whichever the user actually
|
||||
// copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
const trimmed = input.trim()
|
||||
@@ -63,7 +58,7 @@ export function parsePairingCode(input: string): PairingOffer | null {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (/^(?:orca|https):\/\//i.test(trimmed)) {
|
||||
if (/^orca:\/\//i.test(trimmed)) {
|
||||
return decodePairingUrl(trimmed)
|
||||
}
|
||||
return decodePairingBase64(trimmed)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { handlers } = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (...args: unknown[]) => unknown>()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: { isPackaged: false },
|
||||
ipcMain: {
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) =>
|
||||
handlers.set(channel, handler)
|
||||
},
|
||||
shell: { openExternal: vi.fn() }
|
||||
}))
|
||||
|
||||
import { registerMobileHandlers } from './mobile'
|
||||
|
||||
describe('mobile pairing copy link', () => {
|
||||
it('copies a verified https link while the QR keeps the custom scheme older phones scan', async () => {
|
||||
const createMobilePairingOffer = vi.fn().mockResolvedValue({
|
||||
available: true,
|
||||
pairingUrl: 'orca://pair?code=secret-code',
|
||||
endpoint: 'ws://192.168.1.24:6768',
|
||||
deviceId: 'mobile-1',
|
||||
connectionMode: 'local-only'
|
||||
})
|
||||
const encodePairingQr = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, qrDataUrl: 'data:image/png;base64,qr', qrSize: 58 })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getPairingQR with an explicit address calls only createMobilePairingOffer.
|
||||
registerMobileHandlers({ createMobilePairingOffer } as never, { encodePairingQr })
|
||||
|
||||
await expect(
|
||||
handlers.get('mobile:getPairingQR')?.(null, { address: '192.168.1.24' })
|
||||
).resolves.toMatchObject({ pairingUrl: 'https://www.onorca.dev/pair#secret-code' })
|
||||
expect(encodePairingQr).toHaveBeenCalledWith('orca://pair?code=secret-code')
|
||||
})
|
||||
})
|
||||
@@ -346,7 +346,7 @@ describe('registerMobileHandlers', () => {
|
||||
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({
|
||||
available: true,
|
||||
qrSize: 58,
|
||||
pairingUrl: 'https://www.onorca.dev/pair#mobile',
|
||||
pairingUrl: 'orca://pair#mobile',
|
||||
endpoint: 'ws://100.102.47.57:6768',
|
||||
deviceId: 'mobile-1',
|
||||
connectionMode: 'automatic'
|
||||
@@ -426,7 +426,7 @@ describe('registerMobileHandlers', () => {
|
||||
qrDataUrl: null,
|
||||
qrSize: null,
|
||||
qrError: 'encoding_failed',
|
||||
pairingUrl: 'https://www.onorca.dev/pair#copy-me',
|
||||
pairingUrl: 'orca://pair?code=copy-me',
|
||||
endpoint: 'wss://pair.example/oversized',
|
||||
deviceId: 'mobile-large',
|
||||
connectionMode: 'local-only'
|
||||
|
||||
@@ -2,7 +2,6 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
|
||||
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
||||
import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address'
|
||||
import { toVerifiedPairingLink } from '../../shared/pairing'
|
||||
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
|
||||
import type { DeviceEntry } from '../runtime/device-registry'
|
||||
import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance'
|
||||
@@ -135,9 +134,7 @@ export function registerMobileHandlers(
|
||||
qrDataUrl: qr.ok ? qr.qrDataUrl : null,
|
||||
qrSize: qr.ok ? qr.qrSize : null,
|
||||
...(!qr.ok ? { qrError: qr.reason } : {}),
|
||||
// Why: the QR keeps the custom scheme older phones' scanners read, but a copied link can be
|
||||
// tapped, and an `orca://` tap goes to whichever app claimed the scheme.
|
||||
pairingUrl: toVerifiedPairingLink(offer.pairingUrl) ?? offer.pairingUrl,
|
||||
pairingUrl: offer.pairingUrl,
|
||||
// Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at
|
||||
// whichever device scans the QR — never this host. Report no endpoint so the UI omits it
|
||||
// instead of printing an address the phone can't reach.
|
||||
|
||||
@@ -60,25 +60,6 @@ describe('ServeReadinessPublisher', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints a verified phone link for mobile pairing and none for runtime pairing', () => {
|
||||
const mobile: ServeReadiness = {
|
||||
...ready,
|
||||
pairing: {
|
||||
available: true,
|
||||
url: 'orca://pair?code=secret',
|
||||
endpoint: 'wss://orca.example.test/runtime',
|
||||
deviceId: 'device-1',
|
||||
webClientUrl: null,
|
||||
scope: 'mobile',
|
||||
qr: null
|
||||
}
|
||||
}
|
||||
const human = renderServeReadiness(mobile, { mode: 'human' })
|
||||
expect(human).toContain('Pairing URL: orca://pair?code=secret')
|
||||
expect(human).toContain('Phone pairing link: https://www.onorca.dev/pair#secret')
|
||||
expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Phone pairing link')
|
||||
})
|
||||
|
||||
it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => {
|
||||
expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({
|
||||
type: 'orca_server_ready',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc'
|
||||
import type { OrcadHealth } from '../orcad/orcad-health'
|
||||
import { toVerifiedPairingLink } from '../../shared/pairing'
|
||||
|
||||
export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator'
|
||||
|
||||
@@ -120,12 +119,6 @@ function renderHumanReadiness(readiness: ServeReadiness): string {
|
||||
lines.push(`Mobile pairing QR:\n${readiness.pairing.qr}`)
|
||||
}
|
||||
lines.push(`Pairing URL: ${readiness.pairing.url}`)
|
||||
// Why: tapping an `orca://` link on a phone hands the code to whichever app claimed the scheme.
|
||||
const phoneLink =
|
||||
readiness.pairing.scope === 'mobile' ? toVerifiedPairingLink(readiness.pairing.url) : null
|
||||
if (phoneLink) {
|
||||
lines.push(`Phone pairing link: ${phoneLink}`)
|
||||
}
|
||||
} else {
|
||||
lines.push(`Pairing unavailable: ${readiness.pairing.reason}`)
|
||||
lines.push(`Pairing guidance: ${readiness.pairing.guidance}`)
|
||||
|
||||
@@ -25,7 +25,6 @@ export type MobileApi = {
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
/** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
endpoint: string | null
|
||||
|
||||
@@ -27,7 +27,6 @@ export const mobileApi = {
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
/** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
endpoint: string | null
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
encodePairingOffer,
|
||||
decodePairingOffer,
|
||||
parsePairingCode,
|
||||
toVerifiedPairingLink,
|
||||
type PairingOffer
|
||||
} from './pairing'
|
||||
|
||||
@@ -132,17 +131,6 @@ describe('parsePairingCode', () => {
|
||||
expect(parsePairingCode('https://example.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the verified https pairing link a phone receives', () => {
|
||||
const link = toVerifiedPairingLink(encodePairingOffer(offer))
|
||||
expect(link).toMatch(/^https:\/\/www\.onorca\.dev\/pair#/)
|
||||
expect(parsePairingCode(link!)).toEqual(offer)
|
||||
expect(decodePairingOffer(link!)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('returns no verified link for a URL without a pairing code', () => {
|
||||
expect(toVerifiedPairingLink('orca://pair')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid base64 of unrelated JSON', () => {
|
||||
const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64')
|
||||
expect(parsePairingCode(bogus)).toBeNull()
|
||||
|
||||
+3
-16
@@ -7,7 +7,6 @@ import {
|
||||
PAIRING_CODE_MAX_CHARACTERS,
|
||||
PAIRING_INPUT_MAX_CHARACTERS
|
||||
} from './mobile-pairing-protocol-limits'
|
||||
import { buildVerifiedPairingLink, extractVerifiedPairingLinkCode } from './verified-pairing-link'
|
||||
|
||||
export { PAIRING_OFFER_VERSION, PairingOfferSchema }
|
||||
export type { PairingOffer }
|
||||
@@ -33,24 +32,12 @@ export function decodePairingOffer(url: string): PairingOffer {
|
||||
}
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (!code) {
|
||||
throw new Error(
|
||||
'Invalid pairing URL: must be an orca://pair or verified https pairing link with a code'
|
||||
)
|
||||
throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code')
|
||||
}
|
||||
return decodePairingBase64(code)
|
||||
}
|
||||
|
||||
/** The form handed to a phone: the custom scheme can be claimed by any app, this link cannot. */
|
||||
export function toVerifiedPairingLink(pairingUrl: string): string | null {
|
||||
const code = extractPairingCodeFromUrl(pairingUrl)
|
||||
return code ? buildVerifiedPairingLink(code) : null
|
||||
}
|
||||
|
||||
function extractPairingCodeFromUrl(url: string): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return verifiedCode
|
||||
}
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
@@ -72,7 +59,7 @@ function extractPairingCodeFromUrl(url: string): string | null {
|
||||
return parsed.hash ? parsed.hash.slice(1) || null : null
|
||||
}
|
||||
|
||||
// Why: accept an `orca://pair?...` URL, a verified https link, or the bare base64
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// string so the mobile paste-pair flow can take whichever the user
|
||||
// actually copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
@@ -84,7 +71,7 @@ export function parsePairingCode(input: string): PairingOffer | null {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (/^(?:orca|https):\/\//i.test(trimmed)) {
|
||||
if (trimmed.toLowerCase().startsWith('orca://')) {
|
||||
return decodePairingOffer(trimmed)
|
||||
}
|
||||
return decodePairingBase64(trimmed)
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { VERIFIED_PAIRING_LINK_HOST, VERIFIED_PAIRING_LINK_PATH } from './verified-pairing-link'
|
||||
|
||||
const repoRoot = join(__dirname, '..', '..')
|
||||
const deployDir = join(repoRoot, 'config', 'verified-links')
|
||||
const appJson = JSON.parse(readFileSync(join(repoRoot, 'mobile', 'app.json'), 'utf8'))
|
||||
const bundleId: string = appJson.expo.ios.bundleIdentifier
|
||||
const androidPackage: string = appJson.expo.android.package
|
||||
// The build template's public debug key: listing it would let anyone's APK claim the links.
|
||||
const PUBLIC_TEMPLATE_DEBUG_CERT =
|
||||
'FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C'
|
||||
|
||||
function readDeployJson<T>(name: string): T {
|
||||
return JSON.parse(readFileSync(join(deployDir, name), 'utf8'))
|
||||
}
|
||||
|
||||
type ExpoConfig = {
|
||||
ios: { associatedDomains?: string[]; entitlements: Record<string, string> }
|
||||
android: { intentFilters: { autoVerify?: boolean }[] }
|
||||
}
|
||||
|
||||
function loadExpoConfig(associatedDomains: boolean): ExpoConfig {
|
||||
const previous = process.env.ORCA_IOS_ASSOCIATED_DOMAINS
|
||||
process.env.ORCA_IOS_ASSOCIATED_DOMAINS = associatedDomains ? '1' : ''
|
||||
try {
|
||||
const require = createRequire(__filename)
|
||||
const configPath = require.resolve(join(repoRoot, 'mobile', 'app.config.js'))
|
||||
delete require.cache[configPath]
|
||||
return require(configPath)({ config: appJson.expo })
|
||||
} finally {
|
||||
process.env.ORCA_IOS_ASSOCIATED_DOMAINS = previous
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.ORCA_IOS_ASSOCIATED_DOMAINS
|
||||
})
|
||||
|
||||
describe('verified pairing link deploy files', () => {
|
||||
it('scopes the Apple file to the pairing path of the iOS app', () => {
|
||||
const aasa = readDeployJson<{
|
||||
applinks: { details: { appIDs: string[]; components: Record<string, string>[] }[] }
|
||||
}>('apple-app-site-association')
|
||||
expect(Object.keys(aasa)).toEqual(['applinks'])
|
||||
expect(aasa.applinks.details).toHaveLength(1)
|
||||
const [detail] = aasa.applinks.details
|
||||
expect(detail.appIDs).toHaveLength(1)
|
||||
expect(detail.appIDs[0]).toMatch(
|
||||
new RegExp(
|
||||
`^(?:[A-Z0-9]{10}|REPLACE_WITH_APPLE_TEAM_ID)\\.${bundleId.replace(/\./g, '\\.')}$`
|
||||
)
|
||||
)
|
||||
expect(detail.components.map((component) => component['/'])).toEqual([
|
||||
VERIFIED_PAIRING_LINK_PATH
|
||||
])
|
||||
// A query or fragment matcher would narrow nothing we rely on and break the fragment-only secret.
|
||||
expect(detail.components.every((component) => !('?' in component) && !('#' in component))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('binds the Android file to the app package and a private signing key', () => {
|
||||
const statements = readDeployJson<
|
||||
{
|
||||
relation: string[]
|
||||
target: { namespace: string; package_name: string; sha256_cert_fingerprints: string[] }
|
||||
}[]
|
||||
>('assetlinks.json')
|
||||
expect(statements).toHaveLength(1)
|
||||
const [statement] = statements
|
||||
expect(statement.relation).toEqual(['delegate_permission/common.handle_all_urls'])
|
||||
expect(statement.target.namespace).toBe('android_app')
|
||||
expect(statement.target.package_name).toBe(androidPackage)
|
||||
expect(statement.target.sha256_cert_fingerprints.length).toBeGreaterThan(0)
|
||||
for (const fingerprint of statement.target.sha256_cert_fingerprints) {
|
||||
expect(fingerprint).toMatch(
|
||||
/^(?:(?:[0-9A-F]{2}:){31}[0-9A-F]{2}|REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256)$/
|
||||
)
|
||||
expect(fingerprint).not.toBe(PUBLIC_TEMPLATE_DEBUG_CERT)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers the same host and exact path in the Android app config', () => {
|
||||
const config = loadExpoConfig(false)
|
||||
const verifiedFilters = config.android.intentFilters.filter((filter) => filter.autoVerify)
|
||||
expect(verifiedFilters).toEqual([
|
||||
{
|
||||
action: 'VIEW',
|
||||
autoVerify: true,
|
||||
data: [
|
||||
{ scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH }
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('adds the iOS associated domain only when the release opts in', () => {
|
||||
expect(loadExpoConfig(false).ios.associatedDomains).toBeUndefined()
|
||||
const config = loadExpoConfig(true)
|
||||
expect(config.ios.associatedDomains).toEqual([`applinks:${VERIFIED_PAIRING_LINK_HOST}`])
|
||||
expect(config.ios.entitlements['aps-environment']).toBeDefined()
|
||||
})
|
||||
|
||||
it('keeps the fallback page free of external scripts', () => {
|
||||
const html = readFileSync(join(deployDir, 'pair.html'), 'utf8')
|
||||
expect(html).not.toMatch(/<script[^>]+src=/i)
|
||||
expect(html).toContain('window.location.hash')
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
VERIFIED_PAIRING_LINK_HOST,
|
||||
VERIFIED_PAIRING_LINK_PATH,
|
||||
buildVerifiedPairingLink,
|
||||
extractVerifiedPairingLinkCode
|
||||
} from './verified-pairing-link'
|
||||
|
||||
describe('verified pairing link', () => {
|
||||
it('carries the code in the fragment, never the query string', () => {
|
||||
const link = buildVerifiedPairingLink('abc_123-x')
|
||||
expect(link).toBe(
|
||||
`https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#abc_123-x`
|
||||
)
|
||||
expect(link).not.toContain('?')
|
||||
})
|
||||
|
||||
it('round-trips the code', () => {
|
||||
expect(extractVerifiedPairingLinkCode(buildVerifiedPairingLink('abc123'))).toBe('abc123')
|
||||
})
|
||||
|
||||
it('accepts host casing, surrounding whitespace and an ignored query', () => {
|
||||
expect(extractVerifiedPairingLinkCode(' HTTPS://WWW.ONORCA.DEV/pair#abc\n')).toBe('abc')
|
||||
expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?utm=x#abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('never reads a code from the query string', () => {
|
||||
expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?code=abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects other hosts, ports, user info, schemes and paths', () => {
|
||||
for (const url of [
|
||||
'https://onorca.dev.evil.example/pair#abc',
|
||||
'https://evil.example/pair#abc',
|
||||
'https://onorca.dev/pair#abc',
|
||||
'https://www.onorca.dev:8443/pair#abc',
|
||||
'https://user@www.onorca.dev/pair#abc',
|
||||
'http://www.onorca.dev/pair#abc',
|
||||
'https://www.onorca.dev/pairing#abc',
|
||||
'https://www.onorca.dev/pair/#abc',
|
||||
'https://www.onorca.dev/pair-confirm#abc',
|
||||
'https://www.onorca.dev/pair#',
|
||||
'orca://pair#abc'
|
||||
]) {
|
||||
expect(extractVerifiedPairingLinkCode(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,24 +0,0 @@
|
||||
// Why: an `orca://` link can be claimed by any app, so a pairing secret only leaves the desktop as
|
||||
// an https link the OS hands to the app whose identity the domain vouches for. Keep these in sync
|
||||
// with config/verified-links/ and mobile/app.config.js; verified-pairing-link-files.test.ts checks.
|
||||
// Why www: the apex redirects to www, and neither OS verifies a link through a redirect.
|
||||
export const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev'
|
||||
export const VERIFIED_PAIRING_LINK_PATH = '/pair'
|
||||
|
||||
/** The secret rides in the fragment, which browsers never send to the server. */
|
||||
export function buildVerifiedPairingLink(pairingCode: string): string {
|
||||
return `https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#${pairingCode}`
|
||||
}
|
||||
|
||||
/** String parsing only: Hermes's URL polyfill does not expose host or hash reliably. */
|
||||
export function extractVerifiedPairingLinkCode(url: string): string | null {
|
||||
const match = /^https:\/\/([^/?#]*)([^?#]*)(?:\?[^#]*)?#(.*)$/i.exec(url.trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, host, path, code] = match
|
||||
if (host?.toLowerCase() !== VERIFIED_PAIRING_LINK_HOST || path !== VERIFIED_PAIRING_LINK_PATH) {
|
||||
return null
|
||||
}
|
||||
return code || null
|
||||
}
|
||||
Reference in New Issue
Block a user