import type { ComputerMode, ComputerReleaseReason } from "@rakazo/contracts"; import { useLocalSearchParams, useNavigation } from "expo-router"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Modal, Pressable, Text, View } from "react-native"; import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, } from "react-native-safe-area-context"; import { WebView } from "react-native-webview"; import { ComputerMaintenanceActions } from "../components/computer-maintenance-actions"; import { ComputerModePicker } from "../components/computer-mode-picker"; import { NativeSymbol } from "../components/native-symbol"; import { currentApiBase, rpc } from "../lib/api"; import { COMPUTER_HEARTBEAT_MS, COMPUTER_LIFECYCLE_TIMEOUT_MS, type ComputerStatus, computerLabel, controlLabel, embeddableScreenUrl, previewPlaceholder, readScreenUrl, SCREEN_URL_OPEN_ATTEMPTS, } from "../lib/computer"; import { createComputerRefresh } from "../lib/computer-refresh"; import { useI18n } from "../lib/i18n"; import { useMobileTokens } from "../lib/native"; export default function Computer() { const { t } = useI18n(); const tokens = useMobileTokens(); const navigation = useNavigation(); const { botId, name: nameParam } = useLocalSearchParams<{ botId?: string; name?: string }>(); const name = nameParam || t("Bot"); const [computer, setComputer] = useState(null); const [screenUrl, setScreenUrl] = useState(null); const [screenError, setScreenError] = useState(null); const [error, setError] = useState(null); const [readyBotId, setReadyBotId] = useState(null); const [bootingCount, setBootingCount] = useState(0); const [switchingCount, setSwitchingCount] = useState(0); const booting = bootingCount > 0; const switching = switchingCount > 0; const [computerOpen, setComputerOpen] = useState(false); const autoBooted = useRef(null); const embeddedScreenUrl = embeddableScreenUrl(screenUrl, currentApiBase()); useEffect(() => setScreenError(null), [embeddedScreenUrl]); const hasControl = computer?.controlHolder === "user" && computer.controlBotId === botId; const label = computerLabel(computer?.mode, name); useLayoutEffect(() => { navigation.setOptions({ title: label }); }, [label, navigation]); const refreshController = useMemo( () => createComputerRefresh({ readStatus: () => rpc("computer/status", { botId }), readScreen: (attempts) => readScreenUrl(() => rpc<{ url: string | null }>("computer/screenUrl", { botId }), { attempts, }), onStatus: setComputer, onScreen: setScreenUrl, onReady: () => setReadyBotId(botId ?? null), onInitialError: (err) => setError(err instanceof Error ? err.message : String(err)), }), [botId], ); const refresh = refreshController.refresh; useEffect(() => { setComputer(null); setScreenUrl(null); setScreenError(null); setError(null); setReadyBotId(null); setBootingCount(0); setSwitchingCount(0); setComputerOpen(false); autoBooted.current = null; if (botId) refreshController.start(); return () => refreshController.dispose(); }, [botId, refreshController]); async function bootComputer({ takeControl, overlay, force = false, }: { takeControl: boolean; overlay: boolean; force?: boolean; }) { if (!botId || !refreshController.isActive()) return false; const action = refreshController.beginAction(); const needsBoot = force || computer?.state !== "running" || !screenUrl; const showBooting = overlay && needsBoot; if (showBooting) setBootingCount((count) => count + 1); try { if (needsBoot) await rpc("computer/boot", { botId }, { timeoutMs: COMPUTER_LIFECYCLE_TIMEOUT_MS }); if (!action.isActive()) return false; if (takeControl) await rpc("computer/takeover", { botId }); if (!action.isActive()) return false; await action.refresh({ screenAttempts: SCREEN_URL_OPEN_ATTEMPTS }); if (!action.isActive()) return false; setError(null); return true; } catch (err) { if (!action.isActive()) return false; setError(err instanceof Error ? err.message : t("Could not open computer")); throw err; } finally { if (action.isActive() && showBooting) setBootingCount((count) => count - 1); action.finish(); } } useEffect(() => { if (!botId || readyBotId !== botId || switching) return; if (computer?.state === "booting" || computer?.state === "suspended") return; if (autoBooted.current === botId) return; autoBooted.current = botId; void bootComputer({ takeControl: false, overlay: computer?.state !== "running", force: true, }).catch(() => undefined); }, [readyBotId, botId, computer?.state, switching]); useEffect(() => { if (!botId || computer?.state !== "running") return; const ping = () => void rpc("computer/heartbeat", { botId }).catch(() => undefined); ping(); const timer = setInterval(ping, COMPUTER_HEARTBEAT_MS); return () => clearInterval(timer); }, [botId, computer?.state]); async function openComputer() { if (!botId) return; const needsTakeover = !(computer?.controlHolder === "user" && computer.controlBotId === botId); try { const opened = await bootComputer({ takeControl: needsTakeover, overlay: needsTakeover || computer?.state !== "running", force: computer?.state !== "running", }); if (!opened || !refreshController.isActive()) return; setComputerOpen(true); setScreenError(null); } catch { // error already set } } async function releaseComputer(reason?: ComputerReleaseReason) { if (!botId || !refreshController.isActive()) return; const action = refreshController.beginAction(); try { await rpc("computer/release", { botId, reason }); if (!action.isActive()) return; setComputerOpen(false); await action.refresh().catch(() => undefined); } catch { if (action.isActive()) setScreenError(t("Could not continue")); } finally { action.finish(); } } async function setComputerMode(mode: ComputerMode) { if (!botId || !refreshController.isActive() || mode === computer?.mode) return; const action = refreshController.beginAction(); setSwitchingCount((count) => count + 1); setError(null); try { if (hasControl) { await rpc("computer/release", { botId, reason: computer?.takeoverRequested ? "skipped" : undefined, }); } if (!action.isActive()) return; await rpc("bots/setComputer", { botId, mode }, { timeoutMs: COMPUTER_LIFECYCLE_TIMEOUT_MS }); if (!action.isActive()) return; setComputer(null); setScreenUrl(null); autoBooted.current = null; await action.refresh(); } catch (err) { if (!action.isActive()) return; setError(err instanceof Error ? err.message : t("Could not switch computer")); } finally { if (action.isActive()) setSwitchingCount((count) => count - 1); action.finish(); } } const placeholder = screenError ?? previewPlaceholder(computer?.state, booting, name, computer?.mode); return ( {error ? ( {error} ) : null} {computerOpen ? ( {t("Open in full window")} ) : computer?.state === "running" && embeddedScreenUrl && !screenError ? ( { refreshController.invalidateScreen(); setScreenError( t("Could not load the desktop. This device cannot reach the screen URL."), ); }} /> ) : ( {placeholder} )} void openComputer()} style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }} /> {controlLabel(computer, name, botId)} {hasControl ? ( ) : ( void openComputer()} style={{ backgroundColor: tokens.muted, paddingHorizontal: 14, paddingVertical: 10, borderRadius: 12, }} > {t("Take control")} )} {computer ? ( { await refresh(); }} /> ) : null} void setComputerMode(mode)} /> { if (!booting) setComputerOpen(false); }} > {booting ? ( {t("Booting {label}", { label })} ) : ( {label} {hasControl ? ( {t("You have control")} ) : null} {hasControl ? ( ) : ( void bootComputer({ takeControl: true, overlay: false }).catch( () => undefined, ) } hitSlop={8} style={{ borderWidth: 1, borderColor: tokens.border, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, minHeight: 44, justifyContent: "center", }} > {t("Take control")} )} setComputerOpen(false)} style={{ minWidth: 44, minHeight: 44, alignItems: "center", justifyContent: "center", }} > {computer?.state === "running" && embeddedScreenUrl && !screenError ? ( { refreshController.invalidateScreen(); setScreenError( t("Could not load the desktop. This device cannot reach the screen URL."), ); }} /> ) : ( {placeholder} )} )} ); } function ComputerReleaseActions({ takeoverRequested, onRelease, }: { takeoverRequested: boolean; onRelease: (reason?: ComputerReleaseReason) => Promise; }) { const { t } = useI18n(); const tokens = useMobileTokens(); const actions: Array<{ label: string; reason?: ComputerReleaseReason; primary?: boolean }> = takeoverRequested ? [ { label: t("Skip"), reason: "skipped" }, { label: t("I’m done"), reason: "done", primary: true }, ] : [{ label: t("Release") }]; return ( {actions.map((action) => ( void onRelease(action.reason)} hitSlop={8} style={{ minHeight: 44, justifyContent: "center", borderWidth: 1, borderColor: action.primary ? tokens.primary : tokens.border, backgroundColor: action.primary ? tokens.primary : tokens.muted, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 10, }} > {action.label} ))} ); } function ScreenWebView({ url, interactive, onError, }: { url: string; interactive: boolean; onError: () => void; }) { const tokens = useMobileTokens(); return ( ); }