feat(plugins): add host.storage bridge and document sidecar data dir

This commit is contained in:
t8y2 committed 2026-09-21 11:41:58 +08:00
1 parent d3b3a4dbd6
commit 865b9fba84
18 files changed
+700 -8

No files matched your search

@@ -158,6 +158,40 @@ async function readPluginFileChunkById(pluginId: string, handleId: string, offse
return { dataBase64: encodeBytesBase64(bytes), length: bytes.byteLength, eof: offset + bytes.byteLength >= file.size };
}
// --- Plugin UI storage bridge ---------------------------------------------
// Native hosts persist to `plugin-data/<id>/ui-storage.json` through Rust; the
// web host has no plugin-data tree, so entries fall back to the top document's
// localStorage under a per-plugin prefix (same isolation, same JSON values).
function webStorageKey(pluginId: string, key: string): string {
return `dbx-plugin-storage:${pluginId}:${key}`;
}
async function getPluginStorage(pluginId: string, key: string): Promise<unknown> {
if (isTauriRuntime()) {
const { getPluginUiStorage } = await tauriFileApi();
return getPluginUiStorage(pluginId, key);
}
const raw = localStorage.getItem(webStorageKey(pluginId, key));
return raw === null ? null : (JSON.parse(raw) as unknown);
}
async function setPluginStorage(pluginId: string, key: string, value: unknown): Promise<void> {
if (isTauriRuntime()) {
const { setPluginUiStorage } = await tauriFileApi();
return setPluginUiStorage(pluginId, key, value);
}
localStorage.setItem(webStorageKey(pluginId, key), JSON.stringify(value === undefined ? null : value));
}
async function deletePluginStorage(pluginId: string, key: string): Promise<void> {
if (isTauriRuntime()) {
const { deletePluginUiStorage } = await tauriFileApi();
return deletePluginUiStorage(pluginId, key);
}
localStorage.removeItem(webStorageKey(pluginId, key));
}
async function beginPluginFileSave(pluginId: string, request: { name?: string; contentType?: string; size?: number }): Promise<{ handleId: string; chunkBytes: number } | null> {
if (isTauriRuntime()) {
const { save } = await import("@tauri-apps/plugin-dialog");
@@ -355,6 +389,9 @@ function createBridge() {
writeFileChunk: (pluginId, handleId, offset, bytes) => writePluginFileChunkById(pluginId, handleId, offset, bytes),
finishFileSave: (pluginId, handleId) => finishPluginFileSave(pluginId, handleId),
closeFileHandle: (pluginId, handleId) => closePluginFileHandleById(pluginId, handleId),
storageGet: (pluginId, key) => getPluginStorage(pluginId, key),
storageSet: (pluginId, key, value) => setPluginStorage(pluginId, key, value),
storageDelete: (pluginId, key) => deletePluginStorage(pluginId, key),
},
appLocale.value,
currentBridgeTheme(),
+15
View File
@@ -5150,3 +5150,18 @@ export async function writePluginLocalFileChunk(_pluginId: string, _handleId: nu
export async function closePluginLocalFile(_pluginId: string, _handleId: number): Promise<void> {
throw new Error("Plugin local file access is not available in the web backend");
}
// Plugin UI storage goes through the Rust plugin-data tree on native hosts;
// the web workbench host keeps its localStorage fallback in the component, so
// these only exist to satisfy the shared backend surface.
export async function getPluginUiStorage(_pluginId: string, _key: string): Promise<unknown> {
throw new Error("Plugin UI storage is not available in the web backend");
}
export async function setPluginUiStorage(_pluginId: string, _key: string, _value: unknown): Promise<void> {
throw new Error("Plugin UI storage is not available in the web backend");
}
export async function deletePluginUiStorage(_pluginId: string, _key: string): Promise<void> {
throw new Error("Plugin UI storage is not available in the web backend");
}
+12
View File
@@ -2454,6 +2454,18 @@ export async function closePluginLocalFile(pluginId: string, handleId: number):
return invoke("plugin_file_close", { pluginId, handleId });
}
export async function getPluginUiStorage(pluginId: string, key: string): Promise<unknown> {
return invoke("plugin_ui_storage_get", { pluginId, key });
}
export async function setPluginUiStorage(pluginId: string, key: string, value: unknown): Promise<void> {
return invoke("plugin_ui_storage_set", { pluginId, key, value });
}
export async function deletePluginUiStorage(pluginId: string, key: string): Promise<void> {
return invoke("plugin_ui_storage_delete", { pluginId, key });
}
export async function listPluginFilesystemEntries(pluginId: string, providerId: string, options: { connectionId?: string; uri?: string; cursor?: string; limit?: number } = {}): Promise<PluginFilesystemListResult> {
return invoke("list_plugin_filesystem_entries", { pluginId, providerId, ...options });
}
+77
View File
@@ -883,6 +883,83 @@ describe("PluginHostBridge", () => {
expect(messages[0]).toMatchObject({ id: "nocopy", error: "Host clipboard is unavailable" });
});
it("routes host.storage through the owning plugin, caps values, and needs the declared permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const storageGet = vi.fn().mockResolvedValue({ mode: "dark" });
const storageSet = vi.fn().mockResolvedValue(undefined);
const storageDelete = vi.fn().mockResolvedValue(undefined);
const send = (bridge: PluginHostBridge, id: string, method: string, params: unknown) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method, params } } as MessageEvent);
// No declared host.storage permission: every call is refused up front.
const denied = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
storageGet,
storageSet,
storageDelete,
});
send(denied, "denied", "host.storageGet", { key: "theme" });
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "denied", error: "Plugin has not declared permission 'host.storage'" });
expect(storageGet).not.toHaveBeenCalled();
const bridge = new PluginHostBridge(plugin(["host.storage"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
storageGet,
storageSet,
storageDelete,
});
bridge.sendInit();
expect((messages[1] as { capabilities?: { storage?: boolean } }).capabilities?.storage).toBe(true);
send(bridge, "get", "host.storageGet", { key: "theme" });
await vi.waitFor(() => expect(storageGet).toHaveBeenCalledWith("sample", "theme"));
await vi.waitFor(() => expect(messages[2]).toMatchObject({ id: "get", result: { mode: "dark" } }));
send(bridge, "set", "host.storageSet", { key: "theme", value: { mode: "light" } });
await vi.waitFor(() => expect(storageSet).toHaveBeenCalledWith("sample", "theme", { mode: "light" }));
await vi.waitFor(() => expect(messages[3]).toMatchObject({ id: "set", result: null }));
send(bridge, "delete", "host.storageDelete", { key: "theme" });
await vi.waitFor(() => expect(storageDelete).toHaveBeenCalledWith("sample", "theme"));
// `undefined` values normalize to null so the entry round-trips as JSON.
send(bridge, "nullish", "host.storageSet", { key: "empty", value: null });
await vi.waitFor(() => expect(storageSet).toHaveBeenLastCalledWith("sample", "empty", null));
send(bridge, "badkey", "host.storageGet", { key: "" });
send(bridge, "bigvalue", "host.storageSet", { key: "blob", value: "x".repeat(256 * 1024 + 1) });
await vi.waitFor(() => expect(messages).toHaveLength(8));
const byId = new Map(messages.map((message) => [(message as { id?: string }).id, message]));
expect(byId.get("badkey")).toMatchObject({ id: "badkey", error: "storage key is invalid" });
expect(String((byId.get("bigvalue") as { error?: string }).error)).toMatch(/value exceeds/);
});
it("does not advertise or serve host.storage without the host implementation", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(["host.storage"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.sendInit();
expect((messages[0] as { capabilities?: { storage?: boolean } }).capabilities?.storage).toBe(false);
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "nostorage", method: "host.storageGet", params: { key: "theme" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(messages[1]).toMatchObject({ id: "nostorage", error: "Host storage is unavailable" });
});
it("rejects host.saveFile without payload or host support", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
@@ -78,6 +78,15 @@ export interface PluginFileWriteResult {
/** Chunk size the host advertises for streamed saves; fits the bridge payload cap after base64. */
export const PLUGIN_SAVE_CHUNK_BYTES = 1024 * 1024;
/**
* Per-value cap for `host.storage` entries. The whole store is additionally
* capped by the native host; both bounds keep this a UI-state store — bulk
* data belongs to the sidecar's data directory.
*/
export const MAX_PLUGIN_STORAGE_VALUE_BYTES = 256 * 1024;
/** Longest accepted `host.storage` key, mirroring the native host's bound. */
export const MAX_PLUGIN_STORAGE_KEY_CHARS = 256;
export interface PluginHostBridgeApi {
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
@@ -117,6 +126,10 @@ export interface PluginHostBridgeApi {
finishFileSave?(pluginId: string, handleId: string): Promise<void>;
/** Close any file handle, discarding unsaved state. */
closeFileHandle?(pluginId: string, handleId: string): Promise<void>;
/** Persistent per-plugin key-value storage for sandboxed UIs; resolves null when the key is unset. */
storageGet?(pluginId: string, key: string): Promise<unknown>;
storageSet?(pluginId: string, key: string, value: unknown): Promise<void>;
storageDelete?(pluginId: string, key: string): Promise<void>;
}
interface PluginRequestMessage {
@@ -231,7 +244,11 @@ export class PluginHostBridge {
permissions: [...(this.plugin.manifest.permissions || [])],
// Additive capability advertisement: an older host omits `planApi`, and a
// plugin must treat the absence as "unsupported" rather than probing.
capabilities: { downloadFile: !!this.api.downloadFile, planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan },
capabilities: {
downloadFile: !!this.api.downloadFile,
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
},
context: snapshotPluginWorkbenchContext(this.context),
});
}
@@ -440,6 +457,33 @@ export class PluginHostBridge {
await this.api.closeFileHandle(this.plugin.manifest.id, requireHandleId(input.handleId));
return null;
}
if (method === "host.storageGet") {
this.requirePermission("host.storage");
if (!this.api.storageGet) throw new Error("Host storage is unavailable");
const input = requireRecord(params, "host.storageGet params");
return this.api.storageGet(this.plugin.manifest.id, requireStorageKey(input.key));
}
if (method === "host.storageSet") {
this.requirePermission("host.storage");
if (!this.api.storageSet) throw new Error("Host storage is unavailable");
const input = requireRecord(params, "host.storageSet params");
const key = requireStorageKey(input.key);
// Sized before the structured clone reaches the host implementation, so
// the native host and the web fallback enforce the same bound.
const bytes = new TextEncoder().encode(JSON.stringify(input.value ?? null)).byteLength;
if (bytes > MAX_PLUGIN_STORAGE_VALUE_BYTES) {
throw new Error(`host.storage value exceeds ${MAX_PLUGIN_STORAGE_VALUE_BYTES} bytes`);
}
await this.api.storageSet(this.plugin.manifest.id, key, input.value ?? null);
return null;
}
if (method === "host.storageDelete") {
this.requirePermission("host.storage");
if (!this.api.storageDelete) throw new Error("Host storage is unavailable");
const input = requireRecord(params, "host.storageDelete params");
await this.api.storageDelete(this.plugin.manifest.id, requireStorageKey(input.key));
return null;
}
throw new Error(`Unsupported plugin host method '${method}'`);
}
@@ -769,6 +813,13 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
return request('host.saveFile', options, { transfer: bytes });
},
copy: (text) => request('host.copy', { text }),
// Persistent per-plugin key-value state; gate on capabilities.storage
// (older hosts omit it) and declare the host.storage permission.
storage: Object.freeze({
get: (key) => request('host.storageGet', { key }),
set: (key, value) => request('host.storageSet', { key, value: value === undefined ? null : value }),
delete: (key) => request('host.storageDelete', { key }),
}),
fileTransfer: Object.freeze({
pick: (options) => request('host.pickFiles', options || {}),
read: (handleId, offset, length) => request('host.readFileChunk', { handleId, offset, length }),
@@ -936,6 +987,14 @@ function requireChunkLength(value: unknown): number {
return Math.min(8 * 1024 * 1024, Math.floor(value));
}
/** A `host.storage` key: bounded, non-empty, no control characters. */
function requireStorageKey(value: unknown): string {
if (typeof value !== "string" || !value || value.length > MAX_PLUGIN_STORAGE_KEY_CHARS || /[\u0000-\u001f]/.test(value)) {
throw new Error("storage key is invalid");
}
return value;
}
function base64ToBytes(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);