diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c038355b..96c3494a 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -57,7 +57,6 @@ import { } from "@/lib/api"; import { createRuntimeHost, - getHostApi, toRuntimeSurface, } from "@/lib/runtime"; import { projectNameFromPath } from "@/lib/workspace"; @@ -939,8 +938,8 @@ export default function App() { }; const handleNativeEngineRestart = async (): Promise => { - const hostApi = getHostApi(); - if (!hostApi?.restartEngine) { + const runtimeHost = createRuntimeHost(state.runtimeSurface); + if (!runtimeHost.restartEngine) { throw new Error("native engine restart is unavailable"); } rememberRestartRoute(); @@ -950,7 +949,7 @@ export default function App() { // ignore storage errors } try { - await hostApi.restartEngine(); + await runtimeHost.restartEngine(); const refreshed = await refreshReadyClient(state.client, state.runtimeSurface); return refreshed.token; } finally { diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index d9855a0a..8956f282 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -136,7 +136,7 @@ import { type LocalDensity, type LocalPreferences, } from "@/lib/local-preferences"; -import { getHostApi } from "@/lib/runtime"; +import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { fmtDateTime, relativeTime } from "@/lib/format"; import { useLogoFallback } from "@/hooks/useLogoFallback"; @@ -6695,7 +6695,11 @@ function RuntimeSettings({ }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const isNativeHost = getHostApi() !== null || (settings.surface ?? settings.runtime_surface) === "native"; + const runtimeSurface = settings.surface ?? settings.runtime_surface; + const runtimeHost = getRuntimeHost(runtimeSurface, settings.runtime_capabilities); + const openLogs = runtimeHost.openLogs; + const exportDiagnostics = runtimeHost.exportDiagnostics; + const isNativeHost = isNativeRuntime(runtimeSurface); const restartActionLabel = isNativeHost ? tx("app.system.restartEngine", "Restart engine") : t("app.system.restart"); @@ -6709,7 +6713,6 @@ function RuntimeSettings({ } | null>(null); const [hostActionBusy, setHostActionBusy] = useState<"logs" | "diagnostics" | null>(null); - const hostApi = getHostApi(); const apiDefaults = apiService ?? { installed: false, running: false, @@ -6741,11 +6744,11 @@ function RuntimeSettings({ : tx("settings.values.ready", "Ready"); const runHostAction = async ( target: "logs" | "diagnostics", - action: () => Promise, + action: (() => Promise) | undefined, successMessage: (result: string | void) => string, failureMessage: string, ) => { - if (!hostApi) { + if (!action) { setHostActionMessage({ target, message: tx( @@ -6832,7 +6835,7 @@ function RuntimeSettings({ onClick={() => void runHostAction( "logs", - () => hostApi!.openLogs(), + openLogs, () => tx("settings.status.logsOpened", "Opened logs folder."), tx("settings.status.logsOpenFailed", "Could not open logs folder."), ) @@ -6863,11 +6866,11 @@ function RuntimeSettings({ onClick={() => void runHostAction( "diagnostics", - async () => { - const path = await hostApi!.exportDiagnostics(); + exportDiagnostics ? async () => { + const path = await exportDiagnostics(); setDiagnosticsPath(path); return path; - }, + } : undefined, (path) => t("settings.status.diagnosticsExported", { path: String(path ?? ""), @@ -8308,7 +8311,7 @@ function RestartSettingsFooter({ }) { const { t } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); - const isNativeHost = getHostApi() !== null; + const isNativeHost = isNativeRuntime(); const restartLabel = isNativeHost ? tx("app.system.restartEngine", "Restart engine") : t("app.system.restart"); diff --git a/webui/src/components/thread/WorkspaceControls.tsx b/webui/src/components/thread/WorkspaceControls.tsx index 7fef4755..bff2e1c4 100644 --- a/webui/src/components/thread/WorkspaceControls.tsx +++ b/webui/src/components/thread/WorkspaceControls.tsx @@ -15,7 +15,7 @@ import type { WorkspaceScopePayload, WorkspacesPayload, } from "@/lib/types"; -import { getHostApi } from "@/lib/runtime"; +import { getRuntimeHost } from "@/lib/runtime"; import { cn } from "@/lib/utils"; import { isAbsoluteWorkspacePath, @@ -55,8 +55,8 @@ export function WorkspaceProjectPicker({ && !!defaultScope && !!onChange && controls?.can_change_project !== false; - const hostApi = getHostApi(); - const nativeProjectPicker = !!hostApi; + const pickFolder = getRuntimeHost().pickFolder; + const nativeProjectPicker = !!pickFolder; useEffect(() => { if (!open) return; @@ -90,17 +90,17 @@ export function WorkspaceProjectPicker({ ); const pickNativeFolder = useCallback(async () => { - if (!hostApi || disabled) return; + if (!pickFolder || disabled) return; setPickingFolder(true); try { - const picked = await hostApi.pickFolder(); + const picked = await pickFolder(); if (picked) applyProjectPath(picked); } catch (err) { setPathError((err as Error).message); } finally { setPickingFolder(false); } - }, [applyProjectPath, disabled, hostApi]); + }, [applyProjectPath, disabled, pickFolder]); if (!visible || !defaultScope || !onChange) return null; diff --git a/webui/src/lib/runtime.ts b/webui/src/lib/runtime.ts index e11fb70a..64dbcb9f 100644 --- a/webui/src/lib/runtime.ts +++ b/webui/src/lib/runtime.ts @@ -62,7 +62,7 @@ declare global { } } -export function getHostApi(): NanobotHostApi | null { +function getHostApi(): NanobotHostApi | null { if (typeof window === "undefined") return null; return window.nanobotHost ?? null; } @@ -88,13 +88,27 @@ export function createRuntimeHost( surface, capabilities: mergedCapabilities, socketFactory: bridge ? createHostWebSocket : undefined, - pickFolder: api?.pickFolder, - restartEngine: api?.restartEngine, - openLogs: api?.openLogs, - exportDiagnostics: api?.exportDiagnostics, + pickFolder: api ? () => api.pickFolder() : undefined, + restartEngine: api ? () => api.restartEngine() : undefined, + openLogs: api ? () => api.openLogs() : undefined, + exportDiagnostics: api ? () => api.exportDiagnostics() : undefined, }; } +export function getRuntimeHost( + surface?: string | null, + capabilities?: Partial | null, +): RuntimeHost { + const api = getHostApi(); + const runtimeSurface = + surface == null ? (api ? "native" : "browser") : toRuntimeSurface(surface); + return createRuntimeHost(runtimeSurface, capabilities); +} + +export function isNativeRuntime(surface?: string | null): boolean { + return getHostApi() !== null || toRuntimeSurface(surface) === "native"; +} + export function createHostWebSocket(url: string): WebSocket { const api = getHostSocketBridge(); if (!api) { @@ -105,19 +119,20 @@ export function createHostWebSocket(url: string): WebSocket { function getHostSocketBridge(): HostSocketBridge | null { const api = getHostApi(); + const { closeSocket, onSocketEvent, openSocket, sendSocket } = api ?? {}; if ( - !api?.openSocket - || !api.sendSocket - || !api.closeSocket - || !api.onSocketEvent + !openSocket + || !sendSocket + || !closeSocket + || !onSocketEvent ) { return null; } return { - closeSocket: api.closeSocket, - onSocketEvent: api.onSocketEvent, - openSocket: api.openSocket, - sendSocket: api.sendSocket, + closeSocket: (id) => closeSocket.call(api, id), + onSocketEvent: (listener) => onSocketEvent.call(api, listener), + openSocket: (url) => openSocket.call(api, url), + sendSocket: (id, data) => sendSocket.call(api, id, data), }; } diff --git a/webui/src/tests/runtime.test.ts b/webui/src/tests/runtime.test.ts new file mode 100644 index 00000000..73198a38 --- /dev/null +++ b/webui/src/tests/runtime.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime"; + +afterEach(() => { + Reflect.deleteProperty(window, "nanobotHost"); +}); + +describe("runtime host facade", () => { + it("defaults to browser runtime without host actions", () => { + const host = getRuntimeHost(); + + expect(host.surface).toBe("browser"); + expect(host.pickFolder).toBeUndefined(); + expect(isNativeRuntime()).toBe(false); + }); + + it("wraps native host actions behind the runtime facade", async () => { + const pickFolder = vi.fn(async () => "/tmp/project"); + const restartEngine = vi.fn(async () => undefined); + Object.defineProperty(window, "nanobotHost", { + configurable: true, + value: { + getRuntimeInfo: vi.fn(), + restartEngine, + pickFolder, + openLogs: vi.fn(async () => undefined), + exportDiagnostics: vi.fn(async () => "/tmp/diagnostics.txt"), + }, + }); + + const host = getRuntimeHost(); + + expect(host.surface).toBe("native"); + expect(isNativeRuntime()).toBe(true); + await expect(host.pickFolder?.()).resolves.toBe("/tmp/project"); + await host.restartEngine?.(); + expect(pickFolder).toHaveBeenCalledTimes(1); + expect(restartEngine).toHaveBeenCalledTimes(1); + }); + + it("treats server-reported native surface as native for UI labels", () => { + expect(isNativeRuntime("native")).toBe(true); + }); +});