feat(webui): support native folder picker bridges
This commit is contained in:
+124
-10
@@ -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,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 />);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user