refactor mobile screen orchestration

Extract server/session and monitoring workflows into focused hooks so DictationScreen no longer owns every network and notification path. Add a dedicated mobile typecheck config so TypeScript checks pass without breaking Expo export resolution.
pull/19545/head
Ryan Vogel 2026-03-30 08:57:35 -04:00
parent 922633ea9d
commit abf79ae24c
8 changed files with 1357 additions and 1109 deletions

View File

@ -24,6 +24,7 @@ Run all commands from `packages/mobile-voice`.
- iOS run: `bun run ios` - iOS run: `bun run ios`
- Android run: `bun run android` - Android run: `bun run android`
- Lint: `bun run lint` - Lint: `bun run lint`
- Typecheck: `bun run typecheck`
- Expo doctor: `bunx expo-doctor` - Expo doctor: `bunx expo-doctor`
- Dependency compatibility check: `bunx expo install --check` - Dependency compatibility check: `bunx expo install --check`
- Export bundle smoke test: `bunx expo export --platform ios --clear` - Export bundle smoke test: `bunx expo export --platform ios --clear`
@ -31,6 +32,7 @@ Run all commands from `packages/mobile-voice`.
## Build / Verification Expectations ## Build / Verification Expectations
- For JS-only changes: run `bun run lint` and verify app behavior via dev client. - For JS-only changes: run `bun run lint` and verify app behavior via dev client.
- For TS-heavy refactors: run `bun run typecheck` in addition to lint.
- For native dependency/config/plugin changes: rebuild dev client via EAS before validation. - For native dependency/config/plugin changes: rebuild dev client via EAS before validation.
- If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device. - If notifications, camera, microphone, or audio-session behavior changes, verify on a physical iOS device.
- Do not claim a fix unless you validated in Metro logs and app runtime behavior. - Do not claim a fix unless you validated in Metro logs and app runtime behavior.
@ -40,6 +42,7 @@ Run all commands from `packages/mobile-voice`.
- This package currently has no dedicated unit test script. - This package currently has no dedicated unit test script.
- Use targeted validation commands instead: - Use targeted validation commands instead:
- `bun run lint` - `bun run lint`
- `bun run typecheck`
- `bunx expo export --platform ios --clear` - `bunx expo export --platform ios --clear`
- manual runtime test in dev client - manual runtime test in dev client

View File

@ -10,7 +10,8 @@
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web",
"lint": "expo lint" "lint": "expo lint",
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit"
}, },
"dependencies": { "dependencies": {
"@fugood/react-native-audio-pcm-stream": "1.1.4", "@fugood/react-native-audio-pcm-stream": "1.1.4",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,716 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from "react"
import { AppState, Platform, type AppStateStatus } from "react-native"
import * as Haptics from "expo-haptics"
import * as Notifications from "expo-notifications"
import Constants from "expo-constants"
import { fetch as expoFetch } from "expo/fetch"
import {
classifyMonitorEvent,
extractSessionID,
formatMonitorEventLabel,
type OpenCodeEvent,
type MonitorEventType,
} from "@/lib/opencode-events"
import { registerRelayDevice, unregisterRelayDevice } from "@/lib/relay-client"
import { parseSSEStream } from "@/lib/sse"
import { getDevicePushToken, onPushTokenChange } from "@/notifications/monitoring-notifications"
import type { ServerItem } from "@/hooks/use-server-sessions"
export type MonitorJob = {
id: string
sessionID: string
opencodeBaseURL: string
startedAt: number
}
type SessionRuntimeStatus = "idle" | "busy" | "retry"
type PermissionPromptState = "idle" | "pending" | "granted" | "denied"
type NotificationPayload = {
serverID: string | null
eventType: MonitorEventType | null
sessionID: string | null
}
type CuePlayer = {
seekTo: (position: number) => unknown
play: () => unknown
}
type UseMonitoringOptions = {
completePlayer: CuePlayer
closeDropdown: () => void
findServerForSession: (sessionID: string, preferredServerID?: string | null) => Promise<ServerItem | null>
refreshServerStatusAndSessions: (serverID: string, includeSessions?: boolean) => Promise<void>
servers: ServerItem[]
serversRef: MutableRefObject<ServerItem[]>
restoredRef: MutableRefObject<boolean>
activeServerId: string | null
activeSessionId: string | null
activeServerIdRef: MutableRefObject<string | null>
activeSessionIdRef: MutableRefObject<string | null>
setActiveServerId: Dispatch<SetStateAction<string | null>>
setActiveSessionId: Dispatch<SetStateAction<string | null>>
setAgentStateDismissed: Dispatch<SetStateAction<boolean>>
setNotificationPermissionState: Dispatch<SetStateAction<PermissionPromptState>>
}
function parseMonitorEventType(value: unknown): MonitorEventType | null {
if (value === "complete" || value === "permission" || value === "error") {
return value
}
return null
}
function parseNotificationPayload(data: unknown): NotificationPayload | null {
if (!data || typeof data !== "object") return null
const serverIDRaw = (data as { serverID?: unknown }).serverID
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const eventType = parseMonitorEventType((data as { eventType?: unknown }).eventType)
const sessionIDRaw = (data as { sessionID?: unknown }).sessionID
const sessionID = typeof sessionIDRaw === "string" && sessionIDRaw.length > 0 ? sessionIDRaw : null
if (!eventType && !sessionID && !serverID) return null
return {
serverID,
eventType,
sessionID,
}
}
export function useMonitoring({
completePlayer,
closeDropdown,
findServerForSession,
refreshServerStatusAndSessions,
servers,
serversRef,
restoredRef,
activeServerId,
activeSessionId,
activeServerIdRef,
activeSessionIdRef,
setActiveServerId,
setActiveSessionId,
setAgentStateDismissed,
setNotificationPermissionState,
}: UseMonitoringOptions) {
const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
const [monitorStatus, setMonitorStatus] = useState("")
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
const foregroundMonitorAbortRef = useRef<AbortController | null>(null)
const monitorJobRef = useRef<MonitorJob | null>(null)
const pendingNotificationEventsRef = useRef<{ payload: NotificationPayload; source: "received" | "response" }[]>([])
const notificationHandlerRef = useRef<(payload: NotificationPayload, source: "received" | "response") => void>(
(payload, source) => {
pendingNotificationEventsRef.current.push({ payload, source })
},
)
const previousPushTokenRef = useRef<string | null>(null)
const previousAppStateRef = useRef<AppStateStatus>(AppState.currentState)
const latestAssistantRequestRef = useRef(0)
useEffect(() => {
monitorJobRef.current = monitorJob
}, [monitorJob])
useEffect(() => {
const sub = AppState.addEventListener("change", (nextState) => {
setAppState(nextState)
})
return () => sub.remove()
}, [])
useEffect(() => {
let active = true
void (async () => {
try {
if (Platform.OS !== "ios") return
const existing = await Notifications.getPermissionsAsync()
const granted = Boolean((existing as { granted?: unknown }).granted)
if (active) {
setNotificationPermissionState(granted ? "granted" : "idle")
}
if (!granted) return
const token = await getDevicePushToken()
if (token) {
setDevicePushToken(token)
}
} catch {
// Non-fatal: monitoring can still work in-app via foreground SSE.
}
})()
const sub = onPushTokenChange((token) => {
if (!active) return
setDevicePushToken(token)
})
return () => {
active = false
sub.remove()
}
}, [setNotificationPermissionState])
useEffect(() => {
const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "received")
})
const responseSub = Notifications.addNotificationResponseReceivedListener((response: unknown) => {
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification?.request
?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
void Notifications.getLastNotificationResponseAsync()
.then((response) => {
if (!response) return
const data = (response as { notification?: { request?: { content?: { data?: unknown } } } }).notification
?.request?.content?.data
const payload = parseNotificationPayload(data)
if (!payload) return
notificationHandlerRef.current(payload, "response")
})
.catch(() => {})
return () => {
notificationSub.remove()
responseSub.remove()
}
}, [])
const stopForegroundMonitor = useCallback(() => {
const aborter = foregroundMonitorAbortRef.current
if (aborter) {
aborter.abort()
foregroundMonitorAbortRef.current = null
}
}, [])
const loadLatestAssistantResponse = useCallback(
async (baseURL: string, sessionID: string) => {
const requestID = latestAssistantRequestRef.current + 1
latestAssistantRequestRef.current = requestID
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/${sessionID}/message?limit=60`)
if (!response.ok) {
throw new Error(`Session messages failed (${response.status})`)
}
const payload = (await response.json()) as unknown
const text = findLatestAssistantCompletionText(payload)
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse(text)
if (text) {
setAgentStateDismissed(false)
}
} catch {
if (latestAssistantRequestRef.current !== requestID) return
if (activeSessionIdRef.current !== sessionID) return
setLatestAssistantResponse("")
}
},
[activeSessionIdRef, setAgentStateDismissed],
)
const fetchSessionRuntimeStatus = useCallback(
async (baseURL: string, sessionID: string): Promise<SessionRuntimeStatus | null> => {
const base = baseURL.replace(/\/+$/, "")
try {
const response = await fetch(`${base}/session/status`)
if (!response.ok) {
throw new Error(`Session status failed (${response.status})`)
}
const payload = (await response.json()) as unknown
if (!payload || typeof payload !== "object") return null
const status = (payload as Record<string, unknown>)[sessionID]
if (!status || typeof status !== "object") return "idle"
const type = (status as { type?: unknown }).type
if (type === "busy" || type === "retry" || type === "idle") {
return type
}
return null
} catch {
return null
}
},
[],
)
const handleMonitorEvent = useCallback(
(eventType: MonitorEventType, job: MonitorJob) => {
setMonitorStatus(formatMonitorEventLabel(eventType))
if (eventType === "permission") {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning).catch(() => {})
return
}
if (eventType === "complete") {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
void completePlayer.seekTo(0)
void completePlayer.play()
stopForegroundMonitor()
setMonitorJob(null)
void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID)
return
}
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {})
stopForegroundMonitor()
setMonitorJob(null)
},
[completePlayer, loadLatestAssistantResponse, stopForegroundMonitor],
)
const startForegroundMonitor = useCallback(
(job: MonitorJob) => {
stopForegroundMonitor()
const abortController = new AbortController()
foregroundMonitorAbortRef.current = abortController
const base = job.opencodeBaseURL.replace(/\/+$/, "")
void (async () => {
try {
const response = await expoFetch(`${base}/event`, {
signal: abortController.signal,
headers: {
Accept: "text/event-stream",
"Cache-Control": "no-cache",
},
})
if (!response.ok || !response.body) {
throw new Error(`SSE monitor failed (${response.status})`)
}
for await (const message of parseSSEStream(response.body)) {
let parsed: OpenCodeEvent | null = null
try {
parsed = JSON.parse(message.data) as OpenCodeEvent
} catch {
continue
}
if (!parsed) continue
const sessionID = extractSessionID(parsed)
if (sessionID !== job.sessionID) continue
const eventType = classifyMonitorEvent(parsed)
if (!eventType) continue
const active = monitorJobRef.current
if (!active || active.id !== job.id) return
handleMonitorEvent(eventType, job)
}
} catch {
if (abortController.signal.aborted) return
}
})()
},
[handleMonitorEvent, stopForegroundMonitor],
)
const beginMonitoring = useCallback(
async (job: MonitorJob) => {
setMonitorJob(job)
setMonitorStatus("Monitoring…")
startForegroundMonitor(job)
},
[startForegroundMonitor],
)
useEffect(() => {
const active = monitorJobRef.current
if (!active) return
if (appState === "active") {
startForegroundMonitor(active)
return
}
stopForegroundMonitor()
}, [appState, startForegroundMonitor, stopForegroundMonitor])
useEffect(() => {
const active = monitorJobRef.current
if (!active) return
if (activeSessionId === active.sessionID) return
stopForegroundMonitor()
setMonitorJob(null)
setMonitorStatus("")
}, [activeSessionId, stopForegroundMonitor])
useEffect(() => {
setLatestAssistantResponse("")
setAgentStateDismissed(false)
if (!activeServerId || !activeSessionId) return
const server = serversRef.current.find((item) => item.id === activeServerId)
if (!server || server.status !== "online") return
void loadLatestAssistantResponse(server.url, activeSessionId)
}, [activeServerId, activeSessionId, loadLatestAssistantResponse, serversRef, setAgentStateDismissed])
useEffect(() => {
return () => {
stopForegroundMonitor()
}
}, [stopForegroundMonitor])
const syncSessionState = useCallback(
async (input: { serverID: string; sessionID: string; preserveStatusLabel?: boolean }) => {
await refreshServerStatusAndSessions(input.serverID)
const server = serversRef.current.find((item) => item.id === input.serverID)
if (!server || server.status !== "online") return
const runtimeStatus = await fetchSessionRuntimeStatus(server.url, input.sessionID)
await loadLatestAssistantResponse(server.url, input.sessionID)
if (runtimeStatus === "busy" || runtimeStatus === "retry") {
const nextJob: MonitorJob = {
id: `job-resume-${Date.now()}`,
sessionID: input.sessionID,
opencodeBaseURL: server.url.replace(/\/+$/, ""),
startedAt: Date.now(),
}
setMonitorJob(nextJob)
setMonitorStatus("Monitoring…")
if (appState === "active") {
startForegroundMonitor(nextJob)
}
return
}
if (runtimeStatus === "idle") {
stopForegroundMonitor()
setMonitorJob(null)
if (!input.preserveStatusLabel) {
setMonitorStatus("")
}
}
},
[
appState,
fetchSessionRuntimeStatus,
loadLatestAssistantResponse,
refreshServerStatusAndSessions,
serversRef,
startForegroundMonitor,
stopForegroundMonitor,
],
)
const handleNotificationPayload = useCallback(
async (payload: NotificationPayload, source: "received" | "response") => {
const activeServer = activeServerIdRef.current
? serversRef.current.find((server) => server.id === activeServerIdRef.current)
: null
const matchesActiveSession =
!!payload.sessionID &&
activeSessionIdRef.current === payload.sessionID &&
(!payload.serverID || activeServer?.serverID === payload.serverID)
if (payload.eventType && (source === "response" || matchesActiveSession || !payload.sessionID)) {
setMonitorStatus(formatMonitorEventLabel(payload.eventType))
}
if (payload.eventType === "complete" && source === "received") {
void completePlayer.seekTo(0)
void completePlayer.play()
}
if (
(payload.eventType === "complete" || payload.eventType === "error") &&
(source === "response" || matchesActiveSession)
) {
stopForegroundMonitor()
setMonitorJob(null)
}
if (!payload.sessionID) return
if (source === "response") {
const matched = await findServerForSession(payload.sessionID, payload.serverID)
if (!matched) {
console.log("[Notification] open:session-not-found", {
serverID: payload.serverID,
sessionID: payload.sessionID,
eventType: payload.eventType,
})
return
}
activeServerIdRef.current = matched.id
activeSessionIdRef.current = payload.sessionID
setActiveServerId(matched.id)
setActiveSessionId(payload.sessionID)
closeDropdown()
setAgentStateDismissed(false)
await syncSessionState({
serverID: matched.id,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
return
}
if (!matchesActiveSession) return
const activeServerID = activeServerIdRef.current
if (!activeServerID) return
await syncSessionState({
serverID: activeServerID,
sessionID: payload.sessionID,
preserveStatusLabel: Boolean(payload.eventType),
})
},
[
activeServerIdRef,
activeSessionIdRef,
closeDropdown,
completePlayer,
findServerForSession,
serversRef,
setActiveServerId,
setActiveSessionId,
setAgentStateDismissed,
stopForegroundMonitor,
syncSessionState,
],
)
useEffect(() => {
notificationHandlerRef.current = (payload, source) => {
void handleNotificationPayload(payload, source)
}
if (!pendingNotificationEventsRef.current.length) return
const queued = [...pendingNotificationEventsRef.current]
pendingNotificationEventsRef.current = []
queued.forEach(({ payload, source }) => {
void handleNotificationPayload(payload, source)
})
}, [handleNotificationPayload])
useEffect(() => {
const previous = previousAppStateRef.current
previousAppStateRef.current = appState
if (appState !== "active" || previous === "active") return
const serverID = activeServerIdRef.current
const sessionID = activeSessionIdRef.current
if (!serverID || !sessionID) return
void syncSessionState({ serverID, sessionID })
}, [activeServerIdRef, activeSessionIdRef, appState, syncSessionState])
const relayServersKey = useMemo(
() =>
servers
.filter((server) => server.relaySecret.trim().length > 0)
.map((server) => `${server.id}:${server.relayURL}:${server.relaySecret.trim()}`)
.join("|"),
[servers],
)
useEffect(() => {
if (Platform.OS !== "ios") return
if (!devicePushToken) return
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return
const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
const apnsEnv = "production"
console.log("[Relay] env", {
dev: __DEV__,
node: process.env.NODE_ENV,
apnsEnv,
})
console.log("[Relay] register:batch", {
tokenSuffix: devicePushToken.slice(-8),
count: list.length,
apnsEnv,
bundleId,
})
void Promise.allSettled(
list.map(async (server) => {
const secret = server.relaySecret.trim()
const relay = server.relayURL
console.log("[Relay] register:start", {
id: server.id,
relay,
tokenSuffix: devicePushToken.slice(-8),
secretLength: secret.length,
})
try {
await registerRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: devicePushToken,
bundleId,
apnsEnv,
})
console.log("[Relay] register:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] register:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {})
}, [devicePushToken, relayServersKey, serversRef])
useEffect(() => {
if (Platform.OS !== "ios") return
if (!devicePushToken) return
const previous = previousPushTokenRef.current
previousPushTokenRef.current = devicePushToken
if (!previous || previous === devicePushToken) return
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
if (!list.length) return
console.log("[Relay] unregister:batch", {
previousSuffix: previous.slice(-8),
nextSuffix: devicePushToken.slice(-8),
count: list.length,
})
void Promise.allSettled(
list.map(async (server) => {
const secret = server.relaySecret.trim()
const relay = server.relayURL
console.log("[Relay] unregister:start", {
id: server.id,
relay,
tokenSuffix: previous.slice(-8),
secretLength: secret.length,
})
try {
await unregisterRelayDevice({
relayBaseURL: relay,
secret,
deviceToken: previous,
})
console.log("[Relay] unregister:ok", { id: server.id, relay })
} catch (err) {
console.log("[Relay] unregister:error", {
id: server.id,
relay,
error: err instanceof Error ? err.message : String(err),
})
}
}),
).catch(() => {})
}, [devicePushToken, relayServersKey, serversRef])
return {
devicePushToken,
setDevicePushToken,
monitorJob,
monitorStatus,
setMonitorStatus,
latestAssistantResponse,
beginMonitoring,
}
}
type SessionMessageInfo = {
role?: unknown
time?: unknown
}
type SessionMessagePart = {
type?: unknown
text?: unknown
}
type SessionMessagePayload = {
info?: unknown
parts?: unknown
}
function cleanTranscriptText(text: string): string {
return text.replace(/[ \t]+$/gm, "").trimEnd()
}
function cleanSessionText(text: string): string {
return cleanTranscriptText(text).trimStart()
}
function findLatestAssistantCompletionText(payload: unknown): string {
if (!Array.isArray(payload)) return ""
for (let index = payload.length - 1; index >= 0; index -= 1) {
const candidate = payload[index] as SessionMessagePayload
if (!candidate || typeof candidate !== "object") continue
const info = candidate.info as SessionMessageInfo
if (!info || typeof info !== "object") continue
if (info.role !== "assistant") continue
const time = info.time as { completed?: unknown } | undefined
if (!time || typeof time !== "object") continue
if (typeof time.completed !== "number") continue
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
const text = parts
.filter((part) => part && part.type === "text" && typeof part.text === "string")
.map((part) => cleanSessionText(part.text as string))
.filter((part) => part.length > 0)
.join("\n\n")
if (text.length > 0) {
return text
}
}
return ""
}

View File

@ -0,0 +1,386 @@
import { useCallback, useEffect, useRef, useState } from "react"
import {
DEFAULT_RELAY_URL,
parseSessionItems,
persistServerState,
restoreServerState,
serverBases,
looksLikeLocalHost,
type ServerItem,
} from "@/lib/server-sessions"
export { DEFAULT_RELAY_URL, looksLikeLocalHost, type ServerItem, type SessionItem } from "@/lib/server-sessions"
export function useServerSessions() {
const [servers, setServers] = useState<ServerItem[]>([])
const [activeServerId, setActiveServerId] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
const serversRef = useRef<ServerItem[]>([])
const restoredRef = useRef(false)
const refreshSeqRef = useRef<Record<string, number>>({})
const activeServerIdRef = useRef<string | null>(null)
const activeSessionIdRef = useRef<string | null>(null)
useEffect(() => {
serversRef.current = servers
}, [servers])
useEffect(() => {
activeServerIdRef.current = activeServerId
}, [activeServerId])
useEffect(() => {
activeSessionIdRef.current = activeSessionId
}, [activeSessionId])
useEffect(() => {
let mounted = true
void (async () => {
try {
const next = await restoreServerState()
if (!mounted || !next) return
setServers(next.servers)
setActiveServerId(next.activeServerId)
setActiveSessionId(next.activeSessionId)
console.log("[Server] restore", {
count: next.servers.length,
activeServerId: next.activeServerId,
})
} finally {
restoredRef.current = true
}
})()
return () => {
mounted = false
}
}, [])
useEffect(() => {
if (!restoredRef.current) return
void persistServerState(servers, activeServerId, activeSessionId).catch(() => {})
}, [activeServerId, activeSessionId, servers])
const refreshServerStatusAndSessions = useCallback(async (serverID: string, includeSessions = true) => {
const server = serversRef.current.find((item) => item.id === serverID)
if (!server) return
const req = (refreshSeqRef.current[serverID] ?? 0) + 1
refreshSeqRef.current[serverID] = req
const current = () => refreshSeqRef.current[serverID] === req
const candidates = serverBases(server.url)
const base = candidates[0] ?? server.url.replace(/\/+$/, "")
const healthURL = `${base}/health`
const sessionsURL = `${base}/experimental/session?limit=100`
let insecureRemote = false
try {
const parsedBase = new URL(base)
insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname)
} catch {
insecureRemote = base.startsWith("http://")
}
console.log("[Server] refresh:start", {
id: server.id,
name: server.name,
base,
healthURL,
sessionsURL,
includeSessions,
})
setServers((prev) =>
prev.map((item) => (item.id === serverID && includeSessions ? { ...item, sessionsLoading: true } : item)),
)
let activeBase = base
try {
let healthRes: Response | null = null
let healthErr: unknown
for (const item of candidates) {
const url = `${item}/health`
try {
const next = await fetch(url)
if (next.ok) {
healthRes = next
activeBase = item
if (item !== server.url.replace(/\/+$/, "") && current()) {
setServers((prev) => prev.map((entry) => (entry.id === serverID ? { ...entry, url: item } : entry)))
console.log("[Server] refresh:scheme-upgrade", {
id: server.id,
from: server.url,
to: item,
})
}
break
}
healthRes = next
activeBase = item
} catch (err) {
healthErr = err
console.log("[Server] health:attempt-error", {
id: server.id,
url,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
}
}
const online = !!healthRes?.ok
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
console.log("[Server] health", {
id: server.id,
base: activeBase,
url: `${activeBase}/health`,
status: healthRes?.status ?? "fetch_error",
online,
})
if (!online) {
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:offline", {
id: server.id,
base,
candidates,
error: healthErr instanceof Error ? `${healthErr.name}: ${healthErr.message}` : String(healthErr),
})
return
}
if (!includeSessions) {
setServers((prev) =>
prev.map((item) => (item.id === serverID ? { ...item, status: "online", sessionsLoading: false } : item)),
)
console.log("[Server] refresh:online", { id: server.id, base })
return
}
const resolvedSessionsURL = `${activeBase}/experimental/session?limit=100`
const sessionsRes = await fetch(resolvedSessionsURL)
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
if (!sessionsRes.ok) {
console.log("[Server] sessions:http-error", {
id: server.id,
url: resolvedSessionsURL,
status: sessionsRes.status,
})
}
const json = sessionsRes.ok ? await sessionsRes.json() : []
const sessions = parseSessionItems(json)
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "online", sessionsLoading: false, sessions } : item,
),
)
console.log("[Server] sessions", { id: server.id, count: sessions.length })
} catch (err) {
if (!current()) {
console.log("[Server] refresh:stale-skip", { id: server.id, req })
return
}
setServers((prev) =>
prev.map((item) =>
item.id === serverID ? { ...item, status: "offline", sessionsLoading: false, sessions: [] } : item,
),
)
console.log("[Server] refresh:error", {
id: server.id,
base,
healthURL,
sessionsURL,
candidates,
insecureRemote,
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
})
if (insecureRemote) {
console.log("[Server] refresh:hint", {
id: server.id,
message: "Remote http:// host may be blocked by iOS ATS; prefer https:// for non-local hosts.",
})
}
}
}, [])
const refreshAllServerHealth = useCallback(() => {
const ids = serversRef.current.map((item) => item.id)
ids.forEach((id) => {
void refreshServerStatusAndSessions(id, false)
})
}, [refreshServerStatusAndSessions])
const selectServer = useCallback((id: string) => {
setActiveServerId(id)
setActiveSessionId(null)
}, [])
const selectSession = useCallback((id: string) => {
setActiveSessionId(id)
}, [])
const removeServer = useCallback((id: string) => {
setServers((prev) => prev.filter((item) => item.id !== id))
setActiveServerId((prev) => (prev === id ? null : prev))
if (activeServerIdRef.current === id) {
setActiveSessionId(null)
}
}, [])
const addServer = useCallback(
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
const raw = serverURL.trim()
if (!raw) return false
const normalized = raw.startsWith("http://") || raw.startsWith("https://") ? raw : `http://${raw}`
const rawRelay = relayURL.trim()
const relayNormalizedRaw = rawRelay.length > 0 ? rawRelay : DEFAULT_RELAY_URL
const normalizedRelay =
relayNormalizedRaw.startsWith("http://") || relayNormalizedRaw.startsWith("https://")
? relayNormalizedRaw
: `http://${relayNormalizedRaw}`
let parsed: URL
let relayParsed: URL
try {
parsed = new URL(normalized)
relayParsed = new URL(normalizedRelay)
} catch {
return false
}
const id = `srv-${Date.now()}`
const relaySecret = relaySecretRaw.trim()
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
const url = `${parsed.protocol}//${parsed.host}`
const inferredName =
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
const relay = `${relayParsed.protocol}//${relayParsed.host}`
const existing = serversRef.current.find(
(item) =>
item.url === url &&
item.relayURL === relay &&
item.relaySecret.trim() === relaySecret &&
(!serverID || item.serverID === serverID || item.serverID === null),
)
if (existing) {
if (serverID && existing.serverID !== serverID) {
setServers((prev) =>
prev.map((item) => (item.id === existing.id ? { ...item, serverID: serverID ?? item.serverID } : item)),
)
}
setActiveServerId(existing.id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(existing.id)
return true
}
setServers((prev) => [
...prev,
{
id,
name: inferredName,
url,
serverID,
relayURL: relay,
relaySecret,
status: "offline",
sessions: [],
sessionsLoading: false,
},
])
setActiveServerId(id)
setActiveSessionId(null)
void refreshServerStatusAndSessions(id)
return true
},
[refreshServerStatusAndSessions],
)
const findServerForSession = useCallback(
async (sessionID: string, preferredServerID?: string | null): Promise<ServerItem | null> => {
if (!serversRef.current.length && !restoredRef.current) {
for (let attempt = 0; attempt < 20; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150))
if (serversRef.current.length > 0 || restoredRef.current) {
break
}
}
}
if (preferredServerID) {
const preferred = serversRef.current.find((server) => server.serverID === preferredServerID)
if (preferred?.sessions.some((session) => session.id === sessionID)) {
return preferred
}
if (preferred) {
await refreshServerStatusAndSessions(preferred.id)
const refreshed = serversRef.current.find((server) => server.id === preferred.id)
if (refreshed?.sessions.some((session) => session.id === sessionID)) {
return refreshed
}
}
}
const direct = serversRef.current.find((server) => server.sessions.some((session) => session.id === sessionID))
if (direct) return direct
const ids = serversRef.current.map((server) => server.id)
for (const id of ids) {
await refreshServerStatusAndSessions(id)
const matched = serversRef.current.find(
(server) => server.id === id && server.sessions.some((session) => session.id === sessionID),
)
if (matched) {
return matched
}
}
return null
},
[refreshServerStatusAndSessions],
)
return {
servers,
setServers,
serversRef,
activeServerId,
setActiveServerId,
activeServerIdRef,
activeSessionId,
setActiveSessionId,
activeSessionIdRef,
restoredRef,
refreshServerStatusAndSessions,
refreshAllServerHealth,
selectServer,
selectSession,
removeServer,
addServer,
findServerForSession,
}
}

View File

@ -0,0 +1,169 @@
import * as FileSystem from "expo-file-system/legacy"
export const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai"
const SERVER_STATE_FILE = `${FileSystem.documentDirectory}mobile-voice-servers.json`
export type SessionItem = {
id: string
title: string
updated: number
}
type ServerSessionPayload = {
id?: unknown
title?: unknown
time?: {
updated?: unknown
}
}
export type ServerItem = {
id: string
name: string
url: string
serverID: string | null
relayURL: string
relaySecret: string
status: "checking" | "online" | "offline"
sessions: SessionItem[]
sessionsLoading: boolean
}
type SavedServer = {
id: string
name: string
url: string
serverID: string | null
relayURL: string
relaySecret: string
}
type SavedState = {
servers: SavedServer[]
activeServerId: string | null
activeSessionId: string | null
}
export function parseSessionItems(payload: unknown): SessionItem[] {
if (!Array.isArray(payload)) return []
return payload
.filter((item): item is ServerSessionPayload => !!item && typeof item === "object")
.map((item) => ({
id: String(item.id ?? ""),
title: String(item.title ?? item.id ?? "Untitled session"),
updated: Number(item.time?.updated ?? 0),
}))
.filter((item) => item.id.length > 0)
.sort((a, b) => b.updated - a.updated)
}
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
}
export function looksLikeLocalHost(hostname: string): boolean {
return (
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "localhost" ||
hostname.endsWith(".local") ||
hostname.startsWith("10.") ||
hostname.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
isCarrierGradeNat(hostname)
)
}
export function serverBases(input: string): string[] {
const base = input.replace(/\/+$/, "")
const list = [base]
try {
const url = new URL(base)
const local = looksLikeLocalHost(url.hostname)
const tailnet = url.hostname.endsWith(".ts.net")
const secure = `https://${url.host}`
const insecure = `http://${url.host}`
if (url.protocol === "http:" && !local) {
if (tailnet) {
list.unshift(secure)
} else {
list.push(secure)
}
} else if (url.protocol === "https:" && tailnet) {
list.push(insecure)
}
} catch {
// Keep original base only.
}
return [...new Set(list)]
}
function toSaved(servers: ServerItem[], activeServerId: string | null, activeSessionId: string | null): SavedState {
return {
servers: servers.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
serverID: item.serverID,
relayURL: item.relayURL,
relaySecret: item.relaySecret,
})),
activeServerId,
activeSessionId,
}
}
function fromSaved(input: SavedState): {
servers: ServerItem[]
activeServerId: string | null
activeSessionId: string | null
} {
const servers = input.servers.map((item) => ({
id: item.id,
name: item.name,
url: item.url,
serverID: item.serverID ?? null,
relayURL: item.relayURL,
relaySecret: item.relaySecret,
status: "checking" as const,
sessions: [] as SessionItem[],
sessionsLoading: false,
}))
const hasActive = input.activeServerId && servers.some((item) => item.id === input.activeServerId)
const activeServerId = hasActive ? input.activeServerId : (servers[0]?.id ?? null)
return {
servers,
activeServerId,
activeSessionId: hasActive ? input.activeSessionId : null,
}
}
export async function restoreServerState(): Promise<{
servers: ServerItem[]
activeServerId: string | null
activeSessionId: string | null
} | null> {
try {
const data = await FileSystem.readAsStringAsync(SERVER_STATE_FILE)
if (!data) {
return null
}
return fromSaved(JSON.parse(data) as SavedState)
} catch {
return null
}
}
export function persistServerState(
servers: ServerItem[],
activeServerId: string | null,
activeSessionId: string | null,
): Promise<void> {
const payload = toSaved(servers, activeServerId, activeSessionId)
return FileSystem.writeAsStringAsync(SERVER_STATE_FILE, JSON.stringify(payload))
}

View File

@ -2,6 +2,8 @@
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true, "strict": true,
"baseUrl": ".",
"typeRoots": ["./node_modules/@types"],
"paths": { "paths": {
"@/*": ["./src/*"], "@/*": ["./src/*"],
"@/assets/*": ["./assets/*"] "@/assets/*": ["./assets/*"]

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@/assets/*": ["./assets/*"],
"react": ["./node_modules/@types/react"],
"react/jsx-runtime": ["./node_modules/@types/react/jsx-runtime"],
"react/jsx-dev-runtime": ["./node_modules/@types/react/jsx-dev-runtime"]
}
}
}