evOo-local/apps/mobile/app/computer.tsx
cynarAI 6c7eed7580 Initial clean evOo-local from f00577b
Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline
(Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/*
packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
2026-09-20 17:15:16 +02:00

535 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ComputerStatus | null>(null);
const [screenUrl, setScreenUrl] = useState<string | null>(null);
const [screenError, setScreenError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [readyBotId, setReadyBotId] = useState<string | null>(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<string | null>(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<ComputerStatus>("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 (
<View style={{ flex: 1, backgroundColor: tokens.background, padding: 24 }}>
{error ? (
<Text style={{ color: tokens.mutedForeground, marginBottom: 12 }}>{error}</Text>
) : null}
<View
style={{
flex: 1,
minHeight: 220,
borderRadius: 14,
overflow: "hidden",
backgroundColor: tokens.card,
}}
>
{computerOpen ? (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
<Text style={{ color: tokens.mutedForeground }}>{t("Open in full window")}</Text>
</View>
) : computer?.state === "running" && embeddedScreenUrl && !screenError ? (
<ScreenWebView
url={embeddedScreenUrl}
interactive={false}
onError={() => {
refreshController.invalidateScreen();
setScreenError(
t("Could not load the desktop. This device cannot reach the screen URL."),
);
}}
/>
) : (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
<Text style={{ color: tokens.mutedForeground, textAlign: "center" }}>
{placeholder}
</Text>
</View>
)}
<Pressable
accessibilityLabel={t("Open computer")}
onPress={() => void openComputer()}
style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
/>
</View>
<View
style={{
marginTop: 16,
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<Text style={{ color: tokens.mutedForeground, flex: 1 }}>
{controlLabel(computer, name, botId)}
</Text>
{hasControl ? (
<ComputerReleaseActions
takeoverRequested={computer?.takeoverRequested ?? false}
onRelease={releaseComputer}
/>
) : (
<Pressable
onPress={() => void openComputer()}
style={{
backgroundColor: tokens.muted,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 12,
}}
>
<Text style={{ color: tokens.foreground }}>{t("Take control")}</Text>
</Pressable>
)}
</View>
{computer ? (
<ComputerMaintenanceActions
botId={botId ?? ""}
computer={computer}
onChanged={async () => {
await refresh();
}}
/>
) : null}
<ComputerModePicker
value={computer?.mode}
disabled={switching}
onChange={(mode) => void setComputerMode(mode)}
/>
<Modal
visible={booting || computerOpen}
animationType="fade"
presentationStyle="fullScreen"
onRequestClose={() => {
if (!booting) setComputerOpen(false);
}}
>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
{booting ? (
<SafeAreaView
edges={["top", "left", "right"]}
style={{
flex: 1,
backgroundColor: tokens.background,
alignItems: "center",
justifyContent: "center",
gap: 22,
padding: 24,
}}
>
<Text
style={{
color: tokens.foreground,
fontSize: 19,
fontWeight: "500",
textAlign: "center",
}}
>
{t("Booting {label}", { label })}
</Text>
<View
style={{
height: 5,
width: "70%",
maxWidth: 420,
overflow: "hidden",
borderRadius: 999,
backgroundColor: tokens.muted,
}}
>
<View
style={{
height: "100%",
width: "66%",
borderRadius: 999,
backgroundColor: tokens.primary,
}}
/>
</View>
</SafeAreaView>
) : (
<View style={{ flex: 1, backgroundColor: tokens.background }}>
<SafeAreaView
edges={["top", "left", "right"]}
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
borderBottomWidth: 1,
borderBottomColor: tokens.border,
paddingHorizontal: 18,
paddingVertical: 14,
}}
>
<View style={{ flex: 1, minWidth: 0, gap: 8 }}>
<Text
numberOfLines={1}
style={{ color: tokens.foreground, fontSize: 15.5, fontWeight: "500" }}
>
{label}
</Text>
{hasControl ? (
<View
style={{
alignSelf: "flex-start",
borderRadius: 999,
backgroundColor: tokens.muted,
paddingHorizontal: 11,
paddingVertical: 4,
}}
>
<Text style={{ color: tokens.success, fontSize: 13 }}>
{t("You have control")}
</Text>
</View>
) : null}
</View>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
{hasControl ? (
<ComputerReleaseActions
takeoverRequested={computer?.takeoverRequested ?? false}
onRelease={releaseComputer}
/>
) : (
<Pressable
onPress={() =>
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",
}}
>
<Text style={{ color: tokens.foreground }}>{t("Take control")}</Text>
</Pressable>
)}
<Pressable
accessibilityLabel={t("Close computer")}
hitSlop={8}
onPress={() => setComputerOpen(false)}
style={{
minWidth: 44,
minHeight: 44,
alignItems: "center",
justifyContent: "center",
}}
>
<NativeSymbol
ios="xmark"
android="close"
size={16}
color={tokens.mutedForeground}
/>
</Pressable>
</View>
</SafeAreaView>
<View style={{ flex: 1, backgroundColor: tokens.card }}>
{computer?.state === "running" && embeddedScreenUrl && !screenError ? (
<ScreenWebView
url={embeddedScreenUrl}
interactive={hasControl}
onError={() => {
refreshController.invalidateScreen();
setScreenError(
t("Could not load the desktop. This device cannot reach the screen URL."),
);
}}
/>
) : (
<View
style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}
>
<Text style={{ color: tokens.mutedForeground, textAlign: "center" }}>
{placeholder}
</Text>
</View>
)}
</View>
</View>
)}
</SafeAreaProvider>
</Modal>
</View>
);
}
function ComputerReleaseActions({
takeoverRequested,
onRelease,
}: {
takeoverRequested: boolean;
onRelease: (reason?: ComputerReleaseReason) => Promise<void>;
}) {
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 (
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
{actions.map((action) => (
<Pressable
key={action.label}
accessibilityLabel={action.label}
onPress={() => 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,
}}
>
<Text style={{ color: action.primary ? tokens.primaryForeground : tokens.foreground }}>
{action.label}
</Text>
</Pressable>
))}
</View>
);
}
function ScreenWebView({
url,
interactive,
onError,
}: {
url: string;
interactive: boolean;
onError: () => void;
}) {
const tokens = useMobileTokens();
return (
<WebView
key={url}
source={{ uri: url }}
style={{ flex: 1, backgroundColor: tokens.background }}
pointerEvents={interactive ? "auto" : "none"}
javaScriptEnabled
domStorageEnabled
keyboardDisplayRequiresUserAction={false}
allowsInlineMediaPlayback
mediaPlaybackRequiresUserAction={false}
originWhitelist={["*"]}
mixedContentMode="always"
setSupportMultipleWindows={false}
scrollEnabled={false}
overScrollMode="never"
onError={onError}
onHttpError={onError}
/>
);
}