feat(webui): support native folder picker bridges
This commit is contained in:
+124
-10
@@ -24,11 +24,11 @@ export interface HostRuntimeInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NanobotHostApi {
|
export interface NanobotHostApi {
|
||||||
getRuntimeInfo(): Promise<HostRuntimeInfo>;
|
getRuntimeInfo?(): Promise<HostRuntimeInfo>;
|
||||||
restartEngine(): Promise<void>;
|
restartEngine?(): Promise<void>;
|
||||||
pickFolder(): Promise<string | null>;
|
pickFolder?(): Promise<string | null>;
|
||||||
openLogs(): Promise<void>;
|
openLogs?(): Promise<void>;
|
||||||
exportDiagnostics(): Promise<string>;
|
exportDiagnostics?(): Promise<string>;
|
||||||
openSocket?(url: string): Promise<string>;
|
openSocket?(url: string): Promise<string>;
|
||||||
sendSocket?(id: string, data: string): Promise<void>;
|
sendSocket?(id: string, data: string): Promise<void>;
|
||||||
closeSocket?(id: 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_OPEN = 1;
|
||||||
const HOST_WS_CLOSING = 2;
|
const HOST_WS_CLOSING = 2;
|
||||||
const HOST_WS_CLOSED = 3;
|
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 {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
@@ -64,7 +74,21 @@ declare global {
|
|||||||
|
|
||||||
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 ?? 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 {
|
export function toRuntimeSurface(surface: string | null | undefined): RuntimeSurface {
|
||||||
@@ -88,10 +112,10 @@ export function createRuntimeHost(
|
|||||||
surface,
|
surface,
|
||||||
capabilities: mergedCapabilities,
|
capabilities: mergedCapabilities,
|
||||||
socketFactory: bridge ? createHostWebSocket : undefined,
|
socketFactory: bridge ? createHostWebSocket : undefined,
|
||||||
pickFolder: api ? () => api.pickFolder() : undefined,
|
pickFolder: api?.pickFolder?.bind(api),
|
||||||
restartEngine: api ? () => api.restartEngine() : undefined,
|
restartEngine: api?.restartEngine?.bind(api),
|
||||||
openLogs: api ? () => api.openLogs() : undefined,
|
openLogs: api?.openLogs?.bind(api),
|
||||||
exportDiagnostics: api ? () => api.exportDiagnostics() : undefined,
|
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 {
|
class HostWebSocket {
|
||||||
binaryType: BinaryType = "blob";
|
binaryType: BinaryType = "blob";
|
||||||
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
|
onclose: ((this: WebSocket, ev: CloseEvent) => unknown) | null = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
|||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
import { initializeLoopbackRuntimeHost } from "./lib/runtime";
|
||||||
|
|
||||||
// `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost).
|
// `crypto.randomUUID` is only defined in secure contexts (HTTPS or localhost).
|
||||||
// LAN access over plain HTTP leaves it undefined, which crashes components that
|
// 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");
|
const root = document.getElementById("root");
|
||||||
if (!root) throw new Error("root element missing");
|
if (!root) throw new Error("root element missing");
|
||||||
|
|
||||||
|
initializeLoopbackRuntimeHost();
|
||||||
|
|
||||||
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
/* StrictMode disabled: dev double-invokes state updaters; delta accumulation must stay pure — see useNanobotStream. */
|
||||||
ReactDOM.createRoot(root).render(<App />);
|
ReactDOM.createRoot(root).render(<App />);
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { getRuntimeHost, isNativeRuntime } from "@/lib/runtime";
|
import {
|
||||||
|
getRuntimeHost,
|
||||||
|
initializeLoopbackRuntimeHost,
|
||||||
|
isNativeRuntime,
|
||||||
|
} from "@/lib/runtime";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Reflect.deleteProperty(window, "nanobotHost");
|
Reflect.deleteProperty(window, "nanobotHost");
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
window.history.replaceState(null, "", "/");
|
||||||
|
initializeLoopbackRuntimeHost();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("runtime host facade", () => {
|
describe("runtime host facade", () => {
|
||||||
@@ -42,4 +50,46 @@ describe("runtime host facade", () => {
|
|||||||
it("treats server-reported native surface as native for UI labels", () => {
|
it("treats server-reported native surface as native for UI labels", () => {
|
||||||
expect(isNativeRuntime("native")).toBe(true);
|
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