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
parent
922633ea9d
commit
abf79ae24c
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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 ""
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
@ -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/*"]
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue