refactor(webui): centralize native runtime access (#4769)

This commit is contained in:
chengyongru 2026-07-14 15:00:30 +08:00 committed by GitHub
parent b7048cf76a
commit 6c9e3a2cc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 95 additions and 33 deletions

View File

@ -57,7 +57,6 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { import {
createRuntimeHost, createRuntimeHost,
getHostApi,
toRuntimeSurface, toRuntimeSurface,
} from "@/lib/runtime"; } from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace"; import { projectNameFromPath } from "@/lib/workspace";
@ -939,8 +938,8 @@ export default function App() {
}; };
const handleNativeEngineRestart = async (): Promise<string> => { const handleNativeEngineRestart = async (): Promise<string> => {
const hostApi = getHostApi(); const runtimeHost = createRuntimeHost(state.runtimeSurface);
if (!hostApi?.restartEngine) { if (!runtimeHost.restartEngine) {
throw new Error("native engine restart is unavailable"); throw new Error("native engine restart is unavailable");
} }
rememberRestartRoute(); rememberRestartRoute();
@ -950,7 +949,7 @@ export default function App() {
// ignore storage errors // ignore storage errors
} }
try { try {
await hostApi.restartEngine(); await runtimeHost.restartEngine();
const refreshed = await refreshReadyClient(state.client, state.runtimeSurface); const refreshed = await refreshReadyClient(state.client, state.runtimeSurface);
return refreshed.token; return refreshed.token;
} finally { } finally {

View File

@ -136,7 +136,7 @@ import {
type LocalDensity, type LocalDensity,
type LocalPreferences, type LocalPreferences,
} from "@/lib/local-preferences"; } from "@/lib/local-preferences";
import { getHostApi } from "@/lib/runtime"; import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import { fmtDateTime, relativeTime } from "@/lib/format"; import { fmtDateTime, relativeTime } from "@/lib/format";
import { useLogoFallback } from "@/hooks/useLogoFallback"; import { useLogoFallback } from "@/hooks/useLogoFallback";
@ -6695,7 +6695,11 @@ function RuntimeSettings({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); 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 const restartActionLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine") ? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart"); : t("app.system.restart");
@ -6709,7 +6713,6 @@ function RuntimeSettings({
} | null>(null); } | null>(null);
const [hostActionBusy, setHostActionBusy] = const [hostActionBusy, setHostActionBusy] =
useState<"logs" | "diagnostics" | null>(null); useState<"logs" | "diagnostics" | null>(null);
const hostApi = getHostApi();
const apiDefaults = apiService ?? { const apiDefaults = apiService ?? {
installed: false, installed: false,
running: false, running: false,
@ -6741,11 +6744,11 @@ function RuntimeSettings({
: tx("settings.values.ready", "Ready"); : tx("settings.values.ready", "Ready");
const runHostAction = async ( const runHostAction = async (
target: "logs" | "diagnostics", target: "logs" | "diagnostics",
action: () => Promise<string | void>, action: (() => Promise<string | void>) | undefined,
successMessage: (result: string | void) => string, successMessage: (result: string | void) => string,
failureMessage: string, failureMessage: string,
) => { ) => {
if (!hostApi) { if (!action) {
setHostActionMessage({ setHostActionMessage({
target, target,
message: tx( message: tx(
@ -6832,7 +6835,7 @@ function RuntimeSettings({
onClick={() => onClick={() =>
void runHostAction( void runHostAction(
"logs", "logs",
() => hostApi!.openLogs(), openLogs,
() => tx("settings.status.logsOpened", "Opened logs folder."), () => tx("settings.status.logsOpened", "Opened logs folder."),
tx("settings.status.logsOpenFailed", "Could not open logs folder."), tx("settings.status.logsOpenFailed", "Could not open logs folder."),
) )
@ -6863,11 +6866,11 @@ function RuntimeSettings({
onClick={() => onClick={() =>
void runHostAction( void runHostAction(
"diagnostics", "diagnostics",
async () => { exportDiagnostics ? async () => {
const path = await hostApi!.exportDiagnostics(); const path = await exportDiagnostics();
setDiagnosticsPath(path); setDiagnosticsPath(path);
return path; return path;
}, } : undefined,
(path) => (path) =>
t("settings.status.diagnosticsExported", { t("settings.status.diagnosticsExported", {
path: String(path ?? ""), path: String(path ?? ""),
@ -8308,7 +8311,7 @@ function RestartSettingsFooter({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const isNativeHost = getHostApi() !== null; const isNativeHost = isNativeRuntime();
const restartLabel = isNativeHost const restartLabel = isNativeHost
? tx("app.system.restartEngine", "Restart engine") ? tx("app.system.restartEngine", "Restart engine")
: t("app.system.restart"); : t("app.system.restart");

View File

@ -15,7 +15,7 @@ import type {
WorkspaceScopePayload, WorkspaceScopePayload,
WorkspacesPayload, WorkspacesPayload,
} from "@/lib/types"; } from "@/lib/types";
import { getHostApi } from "@/lib/runtime"; import { getRuntimeHost } from "@/lib/runtime";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
isAbsoluteWorkspacePath, isAbsoluteWorkspacePath,
@ -55,8 +55,8 @@ export function WorkspaceProjectPicker({
&& !!defaultScope && !!defaultScope
&& !!onChange && !!onChange
&& controls?.can_change_project !== false; && controls?.can_change_project !== false;
const hostApi = getHostApi(); const pickFolder = getRuntimeHost().pickFolder;
const nativeProjectPicker = !!hostApi; const nativeProjectPicker = !!pickFolder;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@ -90,17 +90,17 @@ export function WorkspaceProjectPicker({
); );
const pickNativeFolder = useCallback(async () => { const pickNativeFolder = useCallback(async () => {
if (!hostApi || disabled) return; if (!pickFolder || disabled) return;
setPickingFolder(true); setPickingFolder(true);
try { try {
const picked = await hostApi.pickFolder(); const picked = await pickFolder();
if (picked) applyProjectPath(picked); if (picked) applyProjectPath(picked);
} catch (err) { } catch (err) {
setPathError((err as Error).message); setPathError((err as Error).message);
} finally { } finally {
setPickingFolder(false); setPickingFolder(false);
} }
}, [applyProjectPath, disabled, hostApi]); }, [applyProjectPath, disabled, pickFolder]);
if (!visible || !defaultScope || !onChange) return null; if (!visible || !defaultScope || !onChange) return null;

View File

@ -62,7 +62,7 @@ declare global {
} }
} }
export function getHostApi(): NanobotHostApi | null { function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null; if (typeof window === "undefined") return null;
return window.nanobotHost ?? null; return window.nanobotHost ?? null;
} }
@ -88,13 +88,27 @@ export function createRuntimeHost(
surface, surface,
capabilities: mergedCapabilities, capabilities: mergedCapabilities,
socketFactory: bridge ? createHostWebSocket : undefined, socketFactory: bridge ? createHostWebSocket : undefined,
pickFolder: api?.pickFolder, pickFolder: api ? () => api.pickFolder() : undefined,
restartEngine: api?.restartEngine, restartEngine: api ? () => api.restartEngine() : undefined,
openLogs: api?.openLogs, openLogs: api ? () => api.openLogs() : undefined,
exportDiagnostics: api?.exportDiagnostics, exportDiagnostics: api ? () => api.exportDiagnostics() : undefined,
}; };
} }
export function getRuntimeHost(
surface?: string | null,
capabilities?: Partial<RuntimeCapabilities> | 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 { export function createHostWebSocket(url: string): WebSocket {
const api = getHostSocketBridge(); const api = getHostSocketBridge();
if (!api) { if (!api) {
@ -105,19 +119,20 @@ export function createHostWebSocket(url: string): WebSocket {
function getHostSocketBridge(): HostSocketBridge | null { function getHostSocketBridge(): HostSocketBridge | null {
const api = getHostApi(); const api = getHostApi();
const { closeSocket, onSocketEvent, openSocket, sendSocket } = api ?? {};
if ( if (
!api?.openSocket !openSocket
|| !api.sendSocket || !sendSocket
|| !api.closeSocket || !closeSocket
|| !api.onSocketEvent || !onSocketEvent
) { ) {
return null; return null;
} }
return { return {
closeSocket: api.closeSocket, closeSocket: (id) => closeSocket.call(api, id),
onSocketEvent: api.onSocketEvent, onSocketEvent: (listener) => onSocketEvent.call(api, listener),
openSocket: api.openSocket, openSocket: (url) => openSocket.call(api, url),
sendSocket: api.sendSocket, sendSocket: (id, data) => sendSocket.call(api, id, data),
}; };
} }

View File

@ -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);
});
});