update mobile pairing flow and audio session handling

Improve pairing reliability and UX by letting users choose among scanned hosts with health checks and cleaner row styling while shrinking QR payloads. Handle iOS call-time audio session conflicts more gracefully with user-friendly messaging and lower-noise logs.
pull/19545/head
Ryan Vogel 2026-03-30 16:53:35 -04:00
parent aacf1d20d3
commit 15fae6cb60
7 changed files with 645 additions and 102 deletions

View File

@ -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"
}

View File

@ -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.
- 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.

View File

@ -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()

View File

@ -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<string | undefined> {
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<string>()
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<Exclude<DropdownMode, "none">>("server")
const [sessionCreateMode, setSessionCreateMode] = useState<"same" | "root" | null>(null)
const [scanOpen, setScanOpen] = useState(false)
const [pairSelectionOpen, setPairSelectionOpen] = useState(false)
const [pendingPair, setPendingPair] = useState<Pair | null>(null)
const [pairHostOptions, setPairHostOptions] = useState<PairHostOption[]>([])
const [selectedPairHostURL, setSelectedPairHostURL] = useState<string | null>(null)
const [pairHostProbes, setPairHostProbes] = useState<Record<string, PairHostProbe>>({})
const [isConnectingPairHost, setIsConnectingPairHost] = useState(false)
const [camGranted, setCamGranted] = useState(false)
const [waveformLevels, setWaveformLevels] = useState<number[]>(Array.from({ length: 24 }, () => 0))
const [waveformTick, setWaveformTick] = useState(0)
@ -419,6 +498,7 @@ export default function DictationScreen() {
const waveformPulseIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const sendSettleTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, PairHostProbe> = {}
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() {
)}
</SafeAreaView>
</Modal>
<Modal
visible={pairSelectionOpen}
animationType="slide"
presentationStyle="formSheet"
onRequestClose={closePairSelection}
>
<SafeAreaView style={styles.pairSelectRoot}>
<View style={styles.pairSelectTop}>
<View style={styles.pairSelectTitleBlock}>
<Text style={styles.pairSelectTitle}>Choose server host</Text>
<Text style={styles.pairSelectSubtitle}>Select the best network route for this server.</Text>
</View>
<Pressable onPress={closePairSelection}>
<Text style={styles.pairSelectClose}>Close</Text>
</Pressable>
</View>
<ScrollView style={styles.pairSelectList} contentContainerStyle={styles.pairSelectListContent}>
{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 (
<Pressable
key={option.url}
onPress={() => setSelectedPairHostURL(option.url)}
style={({ pressed }) => [
styles.pairSelectRow,
selected && styles.pairSelectRowSelected,
index === pairHostOptions.length - 1 && styles.pairSelectRowLast,
pressed && styles.clearButtonPressed,
]}
>
<View style={styles.pairSelectRowMain}>
<View style={styles.pairSelectLeftCol}>
<View style={[styles.pairSelectDot, dotStyle]} />
<View style={styles.pairSelectRowCopy}>
<View style={styles.pairSelectRowTitleLine}>
<Text style={styles.pairSelectHostLabel} numberOfLines={1}>
{option.label}
</Text>
{recommended ? <Text style={styles.pairSelectRecommended}>recommended</Text> : null}
</View>
<Text style={styles.pairSelectHostMeta}>{pairHostKindLabel(option.kind)}</Text>
<Text style={styles.pairSelectProbeMeta}>{pairProbeSummary(probe)}</Text>
<Text style={styles.pairSelectHostURL} numberOfLines={1} ellipsizeMode="middle">
{option.url}
</Text>
</View>
</View>
<View style={styles.pairSelectRightCol}>
<Text style={styles.pairSelectLatency}>{pairProbeLabel(probe)}</Text>
{selected ? (
<SymbolView
name={{
ios: "checkmark",
android: "check",
web: "check",
}}
size={13}
tintColor="#C5C5C5"
/>
) : null}
</View>
</View>
</Pressable>
)
})}
</ScrollView>
<View style={styles.pairSelectFooter}>
<Pressable
onPress={handleConnectSelectedPairHost}
disabled={!selectedPairHostURL || isConnectingPairHost}
style={({ pressed }) => [
styles.pairSelectPrimaryButton,
(!selectedPairHostURL || isConnectingPairHost) && styles.pairSelectPrimaryButtonDisabled,
pressed && styles.clearButtonPressed,
]}
>
<Text style={styles.pairSelectPrimaryButtonText}>
{isConnectingPairHost ? "Connecting..." : "Connect selected host"}
</Text>
</Pressable>
<Pressable
onPress={handleRescanFromPairSelection}
style={({ pressed }) => [pressed && styles.clearButtonPressed]}
>
<Text style={styles.pairSelectSecondaryAction}>Scan again</Text>
</Pressable>
</View>
</SafeAreaView>
</Modal>
</SafeAreaView>
)
}
@ -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",

View File

@ -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)
}

View File

@ -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)

View File

@ -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 ?? [])