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;