diff --git a/packages/mobile-voice/app.json b/packages/mobile-voice/app.json index 0a6a390653..f426749c68 100644 --- a/packages/mobile-voice/app.json +++ b/packages/mobile-voice/app.json @@ -82,9 +82,7 @@ } }, "owner": "anomaly-co", - "runtimeVersion": { - "policy": "appVersion" - }, + "runtimeVersion": "1.0.0", "updates": { "url": "https://u.expo.dev/50b3dac3-8b5e-4142-b749-65ecf7b2904d" } diff --git a/packages/mobile-voice/notes.md b/packages/mobile-voice/notes.md index 3fc5678715..740dc99529 100644 --- a/packages/mobile-voice/notes.md +++ b/packages/mobile-voice/notes.md @@ -1,4 +1,5 @@ - While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly. -- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected. +- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected. - We need some sort of permissions UI in the top half of the generation. -- Need to figure out a good way to start new sessions. \ No newline at end of file +- Need to figure out a good way to start new sessions. +- When an agent returns a generation, we should be able to expand it into a reader mode view. diff --git a/packages/mobile-voice/src/app/_layout.tsx b/packages/mobile-voice/src/app/_layout.tsx index 67571e0b94..1e92f22200 100644 --- a/packages/mobile-voice/src/app/_layout.tsx +++ b/packages/mobile-voice/src/app/_layout.tsx @@ -10,6 +10,8 @@ import { LogBox.ignoreLogs([ "RecordingNotificationManager is not implemented on iOS", "`transcribeRealtime` is deprecated, use `RealtimeTranscriber` instead", + "Parsed error meta:", + "Session activation failed", ]) configureNotificationBehavior() diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index c11061ac2d..f31f0dd339 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -239,6 +239,22 @@ type Pair = { hosts: string[] } +type PairHostKind = "tailnet_name" | "tailnet_ip" | "mdns" | "lan" | "loopback" | "public" | "unknown" + +type PairHostOption = { + url: string + kind: PairHostKind + label: string +} + +type PairHostProbe = { + status: "checking" | "online" | "offline" + latencyMs?: number + note?: string +} + +const AUDIO_SESSION_BUSY_MESSAGE = "Microphone is unavailable while another call is active. End the call and try again." + type Scan = { data: string } @@ -314,51 +330,108 @@ function isLoopback(hostname: string): boolean { return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1" } -/** - * Probe all non-loopback hosts in parallel by hitting /health, then - * choose the first reachable host based on the original ordering. - * This preserves server-side preference (e.g. tailnet before LAN). - */ -async function pickHost(list: string[]): Promise { - const candidates = list.filter((item) => { - try { - return !isLoopback(new URL(item).hostname) - } catch { - return false - } - }) +function isCarrierGradeNat(hostname: string): boolean { + const match = /^100\.(\d{1,3})\./.exec(hostname) + if (!match) return false + const octet = Number(match[1]) + return octet >= 64 && octet <= 127 +} - if (!candidates.length) return list[0] +function classifyPairHost(hostname: string): PairHostKind { + if (isLoopback(hostname)) return "loopback" + if (hostname.endsWith(".ts.net")) return "tailnet_name" + if (isCarrierGradeNat(hostname)) return "tailnet_ip" + if (hostname.endsWith(".local")) return "mdns" + if (hostname.startsWith("10.") || hostname.startsWith("192.168.") || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)) { + return "lan" + } + if (hostname.includes(".")) return "public" + return "unknown" +} - const probes = candidates.map(async (host) => { - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 3000) - try { - const res = await fetch(`${host.replace(/\/+$/, "")}/health`, { - method: "GET", - signal: controller.signal, - }) - return res.ok - } catch { - return false - } finally { - clearTimeout(timeout) - } - }) +function pairHostKindLabel(kind: PairHostKind): string { + switch (kind) { + case "tailnet_name": + return "Tailscale DNS" + case "tailnet_ip": + return "Tailscale IP" + case "mdns": + return "mDNS" + case "lan": + return "LAN" + case "loopback": + return "Loopback" + case "public": + return "Public" + default: + return "Unknown" + } +} - for (let index = 0; index < candidates.length; index += 1) { - const reachable = await probes[index] - if (reachable) { - return candidates[index] - } +function normalizePairHosts(input: string[]): PairHostOption[] { + const seen = new Set() + const normalized = input + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + try { + const parsed = new URL(item) + const url = `${parsed.protocol}//${parsed.host}` + if (seen.has(url)) return null + seen.add(url) + return { + url, + kind: classifyPairHost(parsed.hostname), + label: parsed.hostname, + } as PairHostOption + } catch { + return null + } + }) + .filter((item): item is PairHostOption => !!item) + + const nonLoopback = normalized.filter((item) => item.kind !== "loopback") + return nonLoopback.length > 0 ? nonLoopback : normalized +} + +function pairProbeLabel(probe: PairHostProbe | undefined): string { + if (!probe || probe.status === "checking") return "Checking..." + if (probe.status === "online") return `${probe.latencyMs ?? 0} ms` + return probe.note ?? "Unavailable" +} + +function pairProbeSummary(probe: PairHostProbe | undefined): string { + if (!probe || probe.status === "checking") { + return "Health check in progress" } - // none reachable — keep first candidate as deterministic fallback - try { - return candidates[0] - } catch { - return list[0] + if (probe.status === "online") { + return `Healthy, reached in ${probe.latencyMs ?? 0} ms` } + + return `Health check: ${probe.note ?? "Unavailable"}` +} + +function isAudioSessionBusyError(error: unknown): boolean { + const message = error instanceof Error ? `${error.name} ${error.message}` : String(error ?? "") + return ( + message.includes("InsufficientPriority") || + message.includes("561017449") || + message.includes("Session activation failed") + ) +} + +function normalizeAudioStartErrorMessage(error: unknown): string { + if (isAudioSessionBusyError(error)) { + return AUDIO_SESSION_BUSY_MESSAGE + } + + const raw = error instanceof Error ? error.message.trim() : String(error ?? "").trim() + if (!raw) { + return "Unable to activate microphone." + } + + return raw } export default function DictationScreen() { @@ -392,6 +465,12 @@ export default function DictationScreen() { const [dropdownRenderMode, setDropdownRenderMode] = useState>("server") const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null) const [scanOpen, setScanOpen] = useState(false) + const [pairSelectionOpen, setPairSelectionOpen] = useState(false) + const [pendingPair, setPendingPair] = useState(null) + const [pairHostOptions, setPairHostOptions] = useState([]) + const [selectedPairHostURL, setSelectedPairHostURL] = useState(null) + const [pairHostProbes, setPairHostProbes] = useState>({}) + const [isConnectingPairHost, setIsConnectingPairHost] = useState(false) const [camGranted, setCamGranted] = useState(false) const [waveformLevels, setWaveformLevels] = useState(Array.from({ length: 24 }, () => 0)) const [waveformTick, setWaveformTick] = useState(0) @@ -419,6 +498,7 @@ export default function DictationScreen() { const waveformPulseIntervalRef = useRef | null>(null) const sendSettleTimeoutRef = useRef | null>(null) const scanLockRef = useRef(false) + const pairProbeRunRef = useRef(0) const whisperRestoredRef = useRef(false) const closeDropdown = useCallback(() => { @@ -576,6 +656,29 @@ export default function DictationScreen() { } }, []) + const activateAudioSession = useCallback( + async (trigger: "startup" | "record") => { + try { + await AudioManager.setAudioSessionActivity(true) + return true + } catch (error) { + const message = normalizeAudioStartErrorMessage(error) + if (trigger === "record") { + setWhisperError(message) + } + + if (isAudioSessionBusyError(error)) { + console.warn("[Audio] Session activation deferred:", { trigger, message }) + return false + } + + console.warn("[Audio] Session activation failed:", { trigger, message }) + return false + } + }, + [setWhisperError], + ) + // Set up audio session and check microphone permissions on mount. useEffect(() => { void (async () => { @@ -586,21 +689,22 @@ export default function DictationScreen() { iosOptions: ["allowBluetoothHFP", "defaultToSpeaker"], }) - await AudioManager.setAudioSessionActivity(true) + const sessionReady = await activateAudioSession("startup") const permission = await AudioManager.checkRecordingPermissions() const granted = permission === "Granted" setPermissionGranted(granted) setMicrophonePermissionState(granted ? "granted" : permission === "Denied" ? "denied" : "idle") - if (granted) { + if (granted && sessionReady) { await ensureAudioInputRoute() } } catch (e) { - console.error("Failed to set up audio session:", e) + const message = normalizeAudioStartErrorMessage(e) + console.warn("[Audio] Setup warning:", message) } })() - }, [ensureAudioInputRoute]) + }, [activateAudioSession, ensureAudioInputRoute]) const loadWhisperContext = useCallback( async (modelID: WhisperModelID) => { @@ -847,6 +951,27 @@ export default function DictationScreen() { const cancelled = () => !isRecordingRef.current || activeSessionRef.current !== sessionID try { + const permission = await AudioManager.checkRecordingPermissions() + const granted = permission === "Granted" + setPermissionGranted(granted) + setMicrophonePermissionState(granted ? "granted" : permission === "Denied" ? "denied" : "idle") + + if (!granted) { + setWhisperError("Microphone permission is required to record.") + finalizeRecordingState() + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {}) + return + } + + const sessionReady = await activateAudioSession("record") + if (!sessionReady) { + finalizeRecordingState() + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {}) + return + } + + await ensureAudioInputRoute() + const context = await ensureWhisperModelReady(defaultWhisperModel) if (cancelled()) { isStartingRef.current = false @@ -977,10 +1102,16 @@ export default function DictationScreen() { isStartingRef.current = false } catch (error) { - console.error("[Dictation] Failed to start realtime transcription:", error) - const message = error instanceof Error ? error.message : "Unable to start transcription" + const busy = isAudioSessionBusyError(error) + const message = normalizeAudioStartErrorMessage(error) setWhisperError(message) + if (busy) { + console.warn("[Dictation] Recording blocked while call is active") + } else { + console.error("[Dictation] Failed to start realtime transcription:", error) + } + const activeTranscriber = whisperTranscriberRef.current whisperTranscriberRef.current = null if (activeTranscriber) { @@ -991,7 +1122,9 @@ export default function DictationScreen() { } finalizeRecordingState() - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) + void Haptics.notificationAsync( + busy ? Haptics.NotificationFeedbackType.Warning : Haptics.NotificationFeedbackType.Error, + ).catch(() => {}) } }, [ defaultWhisperModel, @@ -999,6 +1132,8 @@ export default function DictationScreen() { ensureWhisperModelReady, finalizeRecordingState, isTranscribingBulk, + activateAudioSession, + ensureAudioInputRoute, startWaveformPulse, transcriptionMode, transcribedText, @@ -1503,6 +1638,20 @@ export default function DictationScreen() { const showSessionCreationChoices = effectiveDropdownMode === "session" && !!activeServer && activeServer.status === "online" const sessionCreationChoiceCount = showSessionCreationChoices ? (activeSession ? 2 : 1) : 0 + const recommendedPairHostURL = useMemo(() => { + const online = pairHostOptions + .map((item) => ({ item, probe: pairHostProbes[item.url] })) + .filter((entry) => entry.probe?.status === "online") + .sort( + (a, b) => (a.probe?.latencyMs ?? Number.POSITIVE_INFINITY) - (b.probe?.latencyMs ?? Number.POSITIVE_INFINITY), + ) + + if (online[0]) { + return online[0].item.url + } + + return pairHostOptions[0]?.url ?? null + }, [pairHostOptions, pairHostProbes]) const headerTitle = activeServer?.name ?? "No server configured" let headerDotStyle = styles.serverStatusOffline if (activeServer?.status === "online") { @@ -1899,6 +2048,12 @@ export default function DictationScreen() { const handleReplayOnboarding = useCallback(() => { setWhisperSettingsOpen(false) setScanOpen(false) + setPairSelectionOpen(false) + setPendingPair(null) + setPairHostOptions([]) + setPairHostProbes({}) + setSelectedPairHostURL(null) + setIsConnectingPairHost(false) setDropdownMode("none") setOnboardingStep(0) setMicrophonePermissionState(permissionGranted ? "granted" : "idle") @@ -1909,54 +2064,82 @@ export default function DictationScreen() { void FileSystem.deleteAsync(ONBOARDING_STATE_FILE, { idempotent: true }).catch(() => {}) }, [permissionGranted]) - const connectPairPayload = useCallback( - (rawData: string, source: "scan" | "link") => { - const fromScan = source === "scan" - if (fromScan && scanLockRef.current) return + const closePairSelection = useCallback(() => { + setPairSelectionOpen(false) + setPendingPair(null) + setPairHostOptions([]) + setPairHostProbes({}) + setSelectedPairHostURL(null) + setIsConnectingPairHost(false) + pairProbeRunRef.current += 1 + }, []) + const handleConnectSelectedPairHost = useCallback(() => { + if (!pendingPair || !selectedPairHostURL || isConnectingPairHost) { + return + } + + setIsConnectingPairHost(true) + const ok = addServer(selectedPairHostURL, pendingPair.relayURL, pendingPair.relaySecret, pendingPair.serverID) + + if (!ok) { + Alert.alert("Could not add server", "The selected host could not be added. Try another host.") + setIsConnectingPairHost(false) + return + } + + closePairSelection() + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) + }, [addServer, closePairSelection, isConnectingPairHost, pendingPair, selectedPairHostURL]) + + const handleRescanFromPairSelection = useCallback(() => { + closePairSelection() + scanLockRef.current = false + void handleStartScan() + }, [closePairSelection, handleStartScan]) + + const connectPairPayload = useCallback((rawData: string, source: "scan" | "link") => { + const fromScan = source === "scan" + if (fromScan && scanLockRef.current) return + + if (fromScan) { + scanLockRef.current = true + } + + const pair = parsePair(rawData) + if (!pair) { if (fromScan) { - scanLockRef.current = true + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) + setTimeout(() => { + scanLockRef.current = false + }, 750) } + return + } - const pair = parsePair(rawData) - if (!pair) { - if (fromScan) { - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}) - setTimeout(() => { - scanLockRef.current = false - }, 750) - } - return + const options = normalizePairHosts(pair.hosts) + if (!options.length) { + if (fromScan) { + scanLockRef.current = false } + Alert.alert("No valid hosts found", "The QR payload did not include any valid server hosts.") + return + } - void pickHost(pair.hosts) - .then((host) => { - if (!host) { - if (fromScan) { - scanLockRef.current = false - } - return - } + if (fromScan) { + setScanOpen(false) + } - const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID) - if (!ok) { - if (fromScan) { - scanLockRef.current = false - } - return - } + setPendingPair(pair) + setPairHostOptions(options) + setSelectedPairHostURL(options[0]?.url ?? null) + setPairHostProbes(Object.fromEntries(options.map((item) => [item.url, { status: "checking" as const }]))) + setPairSelectionOpen(true) - setScanOpen(false) - void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}) - }) - .catch(() => { - if (fromScan) { - scanLockRef.current = false - } - }) - }, - [addServer], - ) + if (fromScan) { + scanLockRef.current = false + } + }, []) const handleScan = useCallback( (event: Scan) => { @@ -1970,6 +2153,83 @@ export default function DictationScreen() { scanLockRef.current = false }, [scanOpen]) + useEffect(() => { + if (!pairSelectionOpen || !pairHostOptions.length) { + return + } + + const runID = pairProbeRunRef.current + 1 + pairProbeRunRef.current = runID + + setPairHostProbes((prev) => { + const next: Record = {} + for (const option of pairHostOptions) { + next[option.url] = prev[option.url]?.status === "online" ? prev[option.url] : { status: "checking" } + } + return next + }) + + pairHostOptions.forEach((option) => { + void (async () => { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 2800) + const startedAt = Date.now() + + try { + const response = await fetch(`${option.url}/health`, { + method: "GET", + signal: controller.signal, + }) + if (pairProbeRunRef.current !== runID) return + + if (response.ok) { + setPairHostProbes((prev) => ({ + ...prev, + [option.url]: { + status: "online", + latencyMs: Math.max(1, Date.now() - startedAt), + }, + })) + return + } + + setPairHostProbes((prev) => ({ + ...prev, + [option.url]: { + status: "offline", + note: `HTTP ${response.status}`, + }, + })) + } catch (err) { + if (pairProbeRunRef.current !== runID) return + + const aborted = err instanceof Error && err.name === "AbortError" + let note = aborted ? "Timed out" : "Unavailable" + if (!aborted) { + try { + const parsed = new URL(option.url) + if (Platform.OS === "ios" && parsed.protocol === "http:" && !looksLikeLocalHost(parsed.hostname)) { + note = "ATS blocked" + } + } catch { + // ignore parse failure and keep default note + } + } + + setPairHostProbes((prev) => ({ + ...prev, + [option.url]: { + status: "offline", + note, + }, + })) + } finally { + clearTimeout(timeout) + } + })() + }) + }, [pairHostOptions, pairSelectionOpen]) + useEffect(() => { let active = true @@ -2861,6 +3121,109 @@ export default function DictationScreen() { )} + + + + + + Choose server host + Select the best network route for this server. + + + Close + + + + + {pairHostOptions.map((option, index) => { + const probe = pairHostProbes[option.url] + const selected = selectedPairHostURL === option.url + const recommended = recommendedPairHostURL === option.url + let dotStyle = styles.pairSelectDotChecking + if (probe?.status === "online") { + dotStyle = styles.pairSelectDotOnline + } else if (probe?.status === "offline") { + dotStyle = styles.pairSelectDotOffline + } + + return ( + setSelectedPairHostURL(option.url)} + style={({ pressed }) => [ + styles.pairSelectRow, + selected && styles.pairSelectRowSelected, + index === pairHostOptions.length - 1 && styles.pairSelectRowLast, + pressed && styles.clearButtonPressed, + ]} + > + + + + + + + {option.label} + + {recommended ? recommended : null} + + {pairHostKindLabel(option.kind)} + {pairProbeSummary(probe)} + + {option.url} + + + + + + {pairProbeLabel(probe)} + {selected ? ( + + ) : null} + + + + ) + })} + + + + [ + styles.pairSelectPrimaryButton, + (!selectedPairHostURL || isConnectingPairHost) && styles.pairSelectPrimaryButtonDisabled, + pressed && styles.clearButtonPressed, + ]} + > + + {isConnectingPairHost ? "Connecting..." : "Connect selected host"} + + + + [pressed && styles.clearButtonPressed]} + > + Scan again + + + + ) } @@ -3847,6 +4210,170 @@ const styles = StyleSheet.create({ fontSize: 14, textAlign: "center", }, + pairSelectRoot: { + flex: 1, + backgroundColor: "#121212", + paddingHorizontal: 16, + paddingTop: 12, + }, + pairSelectTop: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + marginBottom: 8, + }, + pairSelectTitleBlock: { + flex: 1, + gap: 4, + }, + pairSelectTitle: { + color: "#E8EAF0", + fontSize: 18, + fontWeight: "700", + }, + pairSelectSubtitle: { + color: "#A3A3A3", + fontSize: 13, + fontWeight: "500", + }, + pairSelectClose: { + color: "#C5C5C5", + fontSize: 15, + fontWeight: "600", + }, + pairSelectList: { + flex: 1, + }, + pairSelectListContent: { + paddingBottom: 12, + }, + pairSelectRow: { + minHeight: 74, + borderBottomWidth: 1, + borderBottomColor: "#242424", + paddingVertical: 10, + paddingHorizontal: 10, + }, + pairSelectRowSelected: { + backgroundColor: "#171717", + }, + pairSelectRowLast: { + borderBottomColor: "#242424", + }, + pairSelectRowMain: { + width: "100%", + flexDirection: "row", + alignItems: "flex-start", + justifyContent: "space-between", + gap: 12, + }, + pairSelectLeftCol: { + flex: 1, + minWidth: 0, + flexDirection: "row", + alignItems: "flex-start", + gap: 10, + }, + pairSelectDot: { + width: 8, + height: 8, + borderRadius: 4, + marginTop: 6, + }, + pairSelectDotChecking: { + backgroundColor: "#6F778A", + }, + pairSelectDotOnline: { + backgroundColor: "#5CB76D", + }, + pairSelectDotOffline: { + backgroundColor: "#E35B5B", + }, + pairSelectRowCopy: { + flex: 1, + minWidth: 0, + gap: 2, + }, + pairSelectRowTitleLine: { + flexDirection: "row", + alignItems: "center", + gap: 6, + }, + pairSelectHostLabel: { + color: "#ECECEC", + fontSize: 15, + fontWeight: "600", + flexShrink: 1, + }, + pairSelectRecommended: { + color: "#D5A79F", + fontSize: 10, + fontWeight: "700", + letterSpacing: 0.4, + textTransform: "uppercase", + }, + pairSelectHostMeta: { + color: "#9F9F9F", + fontSize: 12, + fontWeight: "500", + }, + pairSelectProbeMeta: { + color: "#B8B8B8", + fontSize: 12, + fontWeight: "500", + }, + pairSelectHostURL: { + color: "#7E7E7E", + fontSize: 11, + fontWeight: "500", + }, + pairSelectLatency: { + color: "#D4D4D4", + fontSize: 13, + fontWeight: "700", + minWidth: 76, + textAlign: "right", + }, + pairSelectRightCol: { + minWidth: 76, + flexShrink: 0, + alignItems: "flex-end", + gap: 8, + marginLeft: 10, + paddingTop: 2, + }, + pairSelectFooter: { + borderTopWidth: 1, + borderTopColor: "#242424", + paddingTop: 12, + paddingBottom: 10, + gap: 8, + }, + pairSelectPrimaryButton: { + height: 46, + borderRadius: 12, + alignItems: "center", + justifyContent: "center", + backgroundColor: "#1D6FF4", + borderWidth: 2, + borderColor: "#1557C3", + }, + pairSelectPrimaryButtonDisabled: { + opacity: 0.6, + }, + pairSelectPrimaryButtonText: { + color: "#FFFFFF", + fontSize: 15, + fontWeight: "700", + }, + pairSelectSecondaryAction: { + color: "#A8A8A8", + fontSize: 14, + fontWeight: "600", + textAlign: "center", + paddingVertical: 8, + }, sendSlot: { height: CONTROL_HEIGHT, overflow: "hidden", diff --git a/packages/mobile-voice/src/lib/server-sessions.ts b/packages/mobile-voice/src/lib/server-sessions.ts index 4ad426c736..fb83738aa5 100644 --- a/packages/mobile-voice/src/lib/server-sessions.ts +++ b/packages/mobile-voice/src/lib/server-sessions.ts @@ -105,11 +105,7 @@ export function serverBases(input: string): string[] { const secure = `https://${url.host}` const insecure = `http://${url.host}` if (url.protocol === "http:" && !local) { - if (tailnet) { - list.unshift(secure) - } else { - list.push(secure) - } + list.push(secure) } else if (url.protocol === "https:" && tailnet) { list.push(insecure) } diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 5cc7ca4069..9b12e95cdc 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -1,4 +1,4 @@ -import { randomBytes } from "node:crypto" +import { createHash, randomBytes } from "node:crypto" import os from "node:os" import { Server } from "../../server/server" import { cmd } from "./cmd" @@ -8,8 +8,11 @@ import { Workspace } from "../../control-plane/workspace" import { Project } from "../../project/project" import { Installation } from "../../installation" import { PushRelay } from "../../server/push-relay" +import { Log } from "../../util/log" import * as QRCode from "qrcode" +const log = Log.create({ service: "serve" }) + function ipTier(address: string): number { const parts = address.split(".") if (parts.length !== 4) return 4 @@ -70,7 +73,6 @@ function hosts(hostname: string, port: number, advertised: string[] = []) { advertised.forEach(addPreferred) add(hostname) - add("127.0.0.1") Object.values(os.networkInterfaces()) .flatMap((item) => item ?? []) .filter((item) => item.family === "IPv4" && !item.internal) @@ -84,6 +86,11 @@ function pairLink(pair: unknown) { return `mobilevoice:///?pair=${encodeURIComponent(JSON.stringify(pair))}` } +function secretHash(input: string) { + if (!input) return "none" + return `${createHash("sha256").update(input).digest("hex").slice(0, 12)}...` +} + export const ServeCommand = cmd({ command: "serve", builder: (yargs) => @@ -158,10 +165,23 @@ export const ServeCommand = cmd({ if (pair) { console.log("experimental push relay enabled") const link = pairLink(pair) - const code = await QRCode.toString(link, { - type: "terminal", + const qrConfig = { + type: "terminal" as const, small: true, - errorCorrectionLevel: "M", + errorCorrectionLevel: "M" as const, + } + log.info("pair qr", { + relayURL: pair.relayURL, + relaySecretHash: secretHash(pair.relaySecret), + serverID: pair.serverID, + hosts: pair.hosts, + hostCount: pair.hosts.length, + hasLoopbackHost: pair.hosts.some((item) => item.includes("127.0.0.1") || item.includes("localhost")), + linkLength: link.length, + qr: qrConfig, + }) + const code = await QRCode.toString(link, { + ...qrConfig, }) console.log("scan qr code in mobile app or phone camera") console.log(code) diff --git a/packages/opencode/src/server/push-relay.ts b/packages/opencode/src/server/push-relay.ts index 2d493be745..da03d70ebd 100644 --- a/packages/opencode/src/server/push-relay.ts +++ b/packages/opencode/src/server/push-relay.ts @@ -142,7 +142,6 @@ function list(hostname: string, port: number, advertised: string[] = []) { advertised.forEach(addPreferred) add(hostname) - add("127.0.0.1") const nets = Object.values(os.networkInterfaces()) .flatMap((item) => item ?? [])