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:
parent
cb535eef9d
commit
2f44d1900e
2 changed files with 139 additions and 60 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue