refactor(webui): centralize native runtime access (#4769)
This commit is contained in:
+3
-4
@@ -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 {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
+28
-13
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user