feat(plugins): add host.storage bridge and document sidecar data dir
This commit is contained in:
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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user