feat(webui): support native folder picker bridges

This commit is contained in:
Xubin Ren
2026-07-17 12:26:03 +08:00
parent 6519737860
commit b4adb29c2b
3 changed files with 178 additions and 11 deletions
+124 -10
View File
@@ -24,11 +24,11 @@ export interface HostRuntimeInfo {
}
export interface NanobotHostApi {
getRuntimeInfo(): Promise<HostRuntimeInfo>;
restartEngine(): Promise<void>;
pickFolder(): Promise<string | null>;
openLogs(): Promise<void>;
exportDiagnostics(): Promise<string>;
getRuntimeInfo?(): Promise<HostRuntimeInfo>;
restartEngine?(): Promise<void>;
pickFolder?(): Promise<string | null>;
openLogs?(): Promise<void>;
exportDiagnostics?(): Promise<string>;
openSocket?(url: string): Promise<string>;
sendSocket?(id: string, data: string): Promise<void>;
closeSocket?(id: string): Promise<void>;
@@ -55,6 +55,16 @@ const HOST_WS_CONNECTING = 0;
const HOST_WS_OPEN = 1;
const HOST_WS_CLOSING = 2;
const HOST_WS_CLOSED = 3;
const LOOPBACK_HOST_PORT_PARAM = "nativeHostPort";
const LOOPBACK_HOST_TOKEN_PARAM = "nativeHostToken";
const LOOPBACK_HOST_STORAGE_KEY = "nanobot-webui.native-host";
interface LoopbackHostConfig {
port: number;
token: string;
}
let loopbackHostApi: NanobotHostApi | null = null;
declare global {
interface Window {
@@ -64,7 +74,21 @@ declare global {
function getHostApi(): NanobotHostApi | null {
if (typeof window === "undefined") return null;
return window.nanobotHost ?? null;
return window.nanobotHost ?? loopbackHostApi;
}
/**
* Install the external native-host bridge advertised in the URL fragment.
*
* Only a loopback port is accepted; callers cannot redirect privileged host
* actions to an arbitrary origin. The short-lived bridge token is removed
* from the URL and retained only for the lifetime of this browser tab.
*/
export function initializeLoopbackRuntimeHost(): boolean {
if (typeof window === "undefined") return false;
const config = consumeLoopbackHostConfig() ?? loadLoopbackHostConfig();
loopbackHostApi = config ? createLoopbackHostApi(config) : null;
return loopbackHostApi !== null;
}
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
@@ -88,10 +112,10 @@ export function createRuntimeHost(
surface,
capabilities: mergedCapabilities,
socketFactory: bridge ? createHostWebSocket : undefined,
pickFolder: api ? () => api.pickFolder() : undefined,
restartEngine: api ? () => api.restartEngine() : undefined,
openLogs: api ? () => api.openLogs() : undefined,
exportDiagnostics: api ? () => api.exportDiagnostics() : undefined,
pickFolder: api?.pickFolder?.bind(api),
restartEngine: api?.restartEngine?.bind(api),
openLogs: api?.openLogs?.bind(api),
exportDiagnostics: api?.exportDiagnostics?.bind(api),
};
}
@@ -136,6 +160,96 @@ function getHostSocketBridge(): HostSocketBridge | null {
};
}
function consumeLoopbackHostConfig(): LoopbackHostConfig | null {
const hash = window.location.hash || "";
const queryStart = hash.indexOf("?");
if (queryStart < 0) return null;
const path = hash.slice(0, queryStart) || "#/";
const params = new URLSearchParams(hash.slice(queryStart + 1));
const hasBridgeParams = params.has(LOOPBACK_HOST_PORT_PARAM)
|| params.has(LOOPBACK_HOST_TOKEN_PARAM);
if (!hasBridgeParams) return null;
const config = validateLoopbackHostConfig({
port: Number(params.get(LOOPBACK_HOST_PORT_PARAM)),
token: params.get(LOOPBACK_HOST_TOKEN_PARAM) ?? "",
});
params.delete(LOOPBACK_HOST_PORT_PARAM);
params.delete(LOOPBACK_HOST_TOKEN_PARAM);
const nextQuery = params.toString();
const nextHash = `${path}${nextQuery ? `?${nextQuery}` : ""}`;
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.search}${nextHash}`,
);
try {
if (config) {
window.sessionStorage.setItem(LOOPBACK_HOST_STORAGE_KEY, JSON.stringify(config));
} else {
window.sessionStorage.removeItem(LOOPBACK_HOST_STORAGE_KEY);
}
} catch {
// The current page can still use the bridge when session storage is unavailable.
}
return config;
}
function loadLoopbackHostConfig(): LoopbackHostConfig | null {
try {
const raw = window.sessionStorage.getItem(LOOPBACK_HOST_STORAGE_KEY);
if (!raw) return null;
return validateLoopbackHostConfig(JSON.parse(raw) as Partial<LoopbackHostConfig>);
} catch {
return null;
}
}
function validateLoopbackHostConfig(
value: Partial<LoopbackHostConfig>,
): LoopbackHostConfig | null {
const port = Number(value.port);
const token = typeof value.token === "string" ? value.token : "";
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
if (!/^[A-Za-z0-9_-]{32,128}$/.test(token)) return null;
return { port, token };
}
function createLoopbackHostApi(config: LoopbackHostConfig): NanobotHostApi {
return {
async pickFolder(): Promise<string | null> {
let response: Response;
try {
response = await fetch(`http://127.0.0.1:${config.port}/v1/pick-folder`, {
method: "POST",
cache: "no-store",
credentials: "omit",
referrerPolicy: "no-referrer",
headers: { Authorization: `Bearer ${config.token}` },
});
} catch {
throw new Error("Native folder picker is unavailable. Reopen Nanobot and try again.");
}
const body = await response.json().catch(() => null) as {
error?: unknown;
path?: unknown;
} | null;
if (!response.ok) {
const detail = typeof body?.error === "string" ? body.error : `HTTP ${response.status}`;
throw new Error(`Native folder picker failed: ${detail}`);
}
if (body?.path === null) return null;
if (typeof body?.path !== "string" || !body.path) {
throw new Error("Native folder picker returned an invalid path.");
}
return body.path;
},
};
}
class HostWebSocket {
binaryType: BinaryType = "blob";
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
+3
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App";
import "./globals.css";
import "./i18n";
import { initializeLoopbackRuntimeHost } from "./lib/runtime";
// `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost).
// LAN access over plain HTTP leaves it undefined, which crashes components that
@@ -23,5 +24,7 @@ if (typeof globalThis.crypto !== "undefined" && !("randomUUID" in globalThis.cry
const root = document.getElementById("root");
if (!root) throw new Error("root element missing");
initializeLoopbackRuntimeHost();
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
ReactDOM.createRoot(root).render(<App />);
+51 -1
View File
@@ -1,9 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
import {
getRuntimeHost,
initializeLoopbackRuntimeHost,
isNativeRuntime,
} from "@/lib/runtime";
afterEach(() => {
Reflect.deleteProperty(window, "nanobotHost");
window.sessionStorage.clear();
window.history.replaceState(null, "", "/");
initializeLoopbackRuntimeHost();
vi.unstubAllGlobals();
});
describe("runtime host facade", () => {
@@ -42,4 +50,46 @@ describe("runtime host facade", () => {
it("treats server-reported native surface as native for UI labels", () => {
expect(isNativeRuntime("native")).toBe(true);
});
it("installs an authenticated loopback folder picker from the URL fragment", async () => {
const token = "a".repeat(43);
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ path: "/Users/test/project" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
));
vi.stubGlobal("fetch", fetchMock);
window.history.replaceState(
null,
"",
`/#/new?bootstrapSecret=secret&nativeHostPort=43123&nativeHostToken=${token}`,
);
expect(initializeLoopbackRuntimeHost()).toBe(true);
expect(window.location.hash).toBe("#/new?bootstrapSecret=secret");
expect(isNativeRuntime()).toBe(true);
await expect(getRuntimeHost().pickFolder?.()).resolves.toBe("/Users/test/project");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:43123/v1/pick-folder",
expect.objectContaining({
method: "POST",
headers: { Authorization: `Bearer ${token}` },
}),
);
window.history.replaceState(null, "", "/#/new");
expect(initializeLoopbackRuntimeHost()).toBe(true);
await expect(getRuntimeHost().pickFolder?.()).resolves.toBe("/Users/test/project");
});
it("rejects invalid loopback bridge bootstrap values", () => {
window.history.replaceState(
null,
"",
"/#/new?nativeHostPort=70000&nativeHostToken=too-short",
);
expect(initializeLoopbackRuntimeHost()).toBe(false);
expect(window.location.hash).toBe("#/new");
expect(getRuntimeHost().pickFolder).toBeUndefined();
});
});