feat: support deep-link QR pairing in mobile

Generate mobilevoice deep links in serve QR output and let mobile parse both raw payloads and pair query links, while keeping advertised-host ordering and removing QR name overrides.
This commit is contained in:
Ryan Vogel 2026-03-29 19:38:19 -04:00
commit 2f44d1900e
2 changed files with 139 additions and 60 deletions

View file

@ -11,6 +11,7 @@ import {
LayoutChangeEvent, LayoutChangeEvent,
AppState, AppState,
AppStateStatus, AppStateStatus,
Linking,
Platform, Platform,
} from "react-native" } from "react-native"
import Animated, { import Animated, {
@ -342,7 +343,6 @@ type DropdownMode = "none" | "server" | "session"
type Pair = { type Pair = {
v: 1 v: 1
name?: string
serverID?: string serverID?: string
relayURL: string relayURL: string
relaySecret: string relaySecret: string
@ -417,30 +417,55 @@ type Cam = {
requestCameraPermissionsAsync: () => Promise<{ granted: boolean }> requestCameraPermissionsAsync: () => Promise<{ granted: boolean }>
} }
function parsePairShape(data: unknown): Pair | undefined {
if (!data || typeof data !== "object") return
if ((data as { v?: unknown }).v !== 1) return
if (typeof (data as { relayURL?: unknown }).relayURL !== "string") return
if (typeof (data as { relaySecret?: unknown }).relaySecret !== "string") return
if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
if (!hosts.length) return
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
return {
v: 1,
serverID,
relayURL: (data as { relayURL: string }).relayURL,
relaySecret: (data as { relaySecret: string }).relaySecret,
hosts,
}
}
function parsePair(input: string): Pair | undefined { function parsePair(input: string): Pair | undefined {
const raw = input.trim()
if (!raw) return
const candidates: string[] = [raw]
try { try {
const data = JSON.parse(input) const url = new URL(raw)
if (!data || typeof data !== "object") return const query = url.searchParams.get("pair") ?? url.searchParams.get("payload")
if ((data as { v?: unknown }).v !== 1) return if (query) {
if (typeof (data as { relayURL?: unknown }).relayURL !== "string") return candidates.unshift(query)
if (typeof (data as { relaySecret?: unknown }).relaySecret !== "string") return
if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
if (!hosts.length) return
const nameRaw = (data as { name?: unknown }).name
const name = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : undefined
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
return {
v: 1,
name,
serverID,
relayURL: (data as { relayURL: string }).relayURL,
relaySecret: (data as { relaySecret: string }).relaySecret,
hosts,
} }
} catch { } catch {
return // Raw JSON payload is still supported.
}
const seen = new Set<string>()
for (const candidate of candidates) {
if (!candidate || seen.has(candidate)) continue
seen.add(candidate)
try {
const parsed = JSON.parse(candidate)
const pair = parsePairShape(parsed)
if (pair) {
return pair
}
} catch {
// keep trying fallbacks
}
} }
} }
@ -2544,7 +2569,7 @@ export default function DictationScreen() {
) )
const addServer = useCallback( const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string, nameRaw?: string) => { (serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
const raw = serverURL.trim() const raw = serverURL.trim()
if (!raw) return false if (!raw) return false
@ -2569,11 +2594,9 @@ export default function DictationScreen() {
const id = `srv-${Date.now()}` const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim() const relaySecret = relaySecretRaw.trim()
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const explicitName = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : null
const url = `${parsed.protocol}//${parsed.host}` const url = `${parsed.protocol}//${parsed.host}`
const inferredName = const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const name = explicitName ?? inferredName
const relay = `${relayParsed.protocol}//${relayParsed.host}` const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find( const existing = serversRef.current.find(
(item) => (item) =>
@ -2583,17 +2606,9 @@ export default function DictationScreen() {
(!serverID || item.serverID === serverID || item.serverID === null), (!serverID || item.serverID === serverID || item.serverID === null),
) )
if (existing) { if (existing) {
if ((serverID && existing.serverID !== serverID) || (explicitName && existing.name !== explicitName)) { if (serverID && existing.serverID !== serverID) {
setServers((prev) => setServers((prev) =>
prev.map((item) => prev.map((item) => (item.id === existing.id ? { ...item, serverID: serverID ?? item.serverID } : item)),
item.id === existing.id
? {
...item,
name: explicitName ?? item.name,
serverID: serverID ?? item.serverID,
}
: item,
),
) )
} }
setActiveServerId(existing.id) setActiveServerId(existing.id)
@ -2607,7 +2622,7 @@ export default function DictationScreen() {
...prev, ...prev,
{ {
id, id,
name, name: inferredName,
url, url,
serverID, serverID,
relayURL: relay, relayURL: relay,
@ -2695,43 +2710,101 @@ export default function DictationScreen() {
FileSystem.deleteAsync(ONBOARDING_STATE_FILE, { idempotent: true }).catch(() => {}) FileSystem.deleteAsync(ONBOARDING_STATE_FILE, { idempotent: true }).catch(() => {})
}, [permissionGranted]) }, [permissionGranted])
const handleScan = useCallback( const connectPairPayload = useCallback(
(event: Scan) => { (rawData: string, source: "scan" | "link") => {
if (scanLockRef.current) return const fromScan = source === "scan"
scanLockRef.current = true if (fromScan && scanLockRef.current) return
const pair = parsePair(event.data)
if (fromScan) {
scanLockRef.current = true
}
const pair = parsePair(rawData)
if (!pair) { if (!pair) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) if (fromScan) {
setTimeout(() => { Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
scanLockRef.current = false setTimeout(() => {
}, 750) scanLockRef.current = false
}, 750)
}
return return
} }
void pickHost(pair.hosts).then((host) => { void pickHost(pair.hosts)
if (!host) { .then((host) => {
scanLockRef.current = false if (!host) {
return if (fromScan) {
} scanLockRef.current = false
}
return
}
const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID, pair.name) const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID)
if (!ok) { if (!ok) {
scanLockRef.current = false if (fromScan) {
return scanLockRef.current = false
} }
return
}
setScanOpen(false) setScanOpen(false)
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
}) })
.catch(() => {
if (fromScan) {
scanLockRef.current = false
}
})
}, },
[addServer], [addServer],
) )
const handleScan = useCallback(
(event: Scan) => {
connectPairPayload(event.data, "scan")
},
[connectPairPayload],
)
useEffect(() => { useEffect(() => {
if (scanOpen) return if (scanOpen) return
scanLockRef.current = false scanLockRef.current = false
}, [scanOpen]) }, [scanOpen])
useEffect(() => {
let active = true
const handleURL = async (url: string | null) => {
if (!url) return
if (!parsePair(url)) return
if (!restoredRef.current) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 100))
if (restoredRef.current || !active) {
break
}
}
}
if (!active) return
connectPairPayload(url, "link")
}
void Linking.getInitialURL()
.then((url) => handleURL(url))
.catch(() => {})
const sub = Linking.addEventListener("url", (event) => {
void handleURL(event.url)
})
return () => {
active = false
sub.remove()
}
}, [connectPairPayload])
useEffect(() => { useEffect(() => {
if (!activeServerId) return if (!activeServerId) return
refreshServerStatusAndSessions(activeServerId) refreshServerStatusAndSessions(activeServerId)

View file

@ -80,6 +80,10 @@ function hosts(hostname: string, port: number, advertised: string[] = []) {
return [...preferred, ...entries.map((item) => item.url)] return [...preferred, ...entries.map((item) => item.url)]
} }
function pairLink(pair: unknown) {
return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}`
}
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", command: "serve",
builder: (yargs) => builder: (yargs) =>
@ -153,14 +157,16 @@ export const ServeCommand = cmd({
} }
if (pair) { if (pair) {
console.log("experimental push relay enabled") console.log("experimental push relay enabled")
const payload = JSON.stringify(pair) const link = pairLink(pair)
const code = await QRCode.toString(payload, { const code = await QRCode.toString(link, {
type: "terminal", type: "terminal",
small: true, small: true,
errorCorrectionLevel: "M", errorCorrectionLevel: "M",
}) })
console.log("scan qr code in mobile app") console.log("scan qr code in mobile app or phone camera")
console.log(code) console.log(code)
console.log("qr link")
console.log(link)
console.log("qr payload") console.log("qr payload")
console.log(JSON.stringify(pair, null, 2)) console.log(JSON.stringify(pair, null, 2))
} }