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;
|
||||
|
||||
Reference in New Issue
Block a user