From 865b9fba8434864b8dcf1c78843353133371a35c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 21 Sep 2026 11:41:58 +0800 Subject: [PATCH] feat(plugins): add host.storage bridge and document sidecar data dir --- .../plugins/PluginWorkbenchHost.vue | 37 +++ apps/desktop/src/lib/backend/http.ts | 15 + apps/desktop/src/lib/backend/tauri.ts | 12 + .../src/lib/plugins/pluginHostBridge.spec.ts | 77 +++++ .../src/lib/plugins/pluginHostBridge.ts | 61 +++- .../src/plugins/manifest.rs | 2 +- docs/content/docs/plugin-development.cn.mdx | 28 +- docs/content/docs/plugin-development.mdx | 28 +- plugins/README.md | 5 +- plugins/manifest.schema.json | 2 +- plugins/sdk/dev-host/browser-bridge.mjs | 5 + plugins/sdk/dev-host/server.mjs | 44 ++- plugins/sdk/dev-host/tests/server.test.mjs | 32 ++ plugins/sdk/go/dbx-plugin-sdk/sdk.go | 25 ++ plugins/sdk/go/dbx-plugin-sdk/sdk_test.go | 33 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/plugin_storage.rs | 297 ++++++++++++++++++ src-tauri/src/lib.rs | 4 + 18 files changed, 700 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/commands/plugin_storage.rs diff --git a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue index 4686263c46..55bd305455 100644 --- a/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue +++ b/apps/desktop/src/components/plugins/PluginWorkbenchHost.vue @@ -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//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 { + 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 { + 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 { + 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(), diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index afef45d58e..0707fdf227 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -5150,3 +5150,18 @@ export async function writePluginLocalFileChunk(_pluginId: string, _handleId: nu export async function closePluginLocalFile(_pluginId: string, _handleId: number): Promise { 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 { + throw new Error("Plugin UI storage is not available in the web backend"); +} + +export async function setPluginUiStorage(_pluginId: string, _key: string, _value: unknown): Promise { + throw new Error("Plugin UI storage is not available in the web backend"); +} + +export async function deletePluginUiStorage(_pluginId: string, _key: string): Promise { + throw new Error("Plugin UI storage is not available in the web backend"); +} diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index e96fa3bdfb..af19263332 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { + return invoke("plugin_ui_storage_get", { pluginId, key }); +} + +export async function setPluginUiStorage(pluginId: string, key: string, value: unknown): Promise { + return invoke("plugin_ui_storage_set", { pluginId, key, value }); +} + +export async function deletePluginUiStorage(pluginId: string, key: string): Promise { + 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 { return invoke("list_plugin_filesystem_entries", { pluginId, providerId, ...options }); } diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts index 0679fc9eef..0729f5108c 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.spec.ts @@ -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; diff --git a/apps/desktop/src/lib/plugins/pluginHostBridge.ts b/apps/desktop/src/lib/plugins/pluginHostBridge.ts index bc51c64e85..0b40c99a35 100644 --- a/apps/desktop/src/lib/plugins/pluginHostBridge.ts +++ b/apps/desktop/src/lib/plugins/pluginHostBridge.ts @@ -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(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise; notify(pluginId: string, method: string, params?: unknown): Promise; @@ -117,6 +126,10 @@ export interface PluginHostBridgeApi { finishFileSave?(pluginId: string, handleId: string): Promise; /** Close any file handle, discarding unsaved state. */ closeFileHandle?(pluginId: string, handleId: string): Promise; + /** Persistent per-plugin key-value storage for sandboxed UIs; resolves null when the key is unset. */ + storageGet?(pluginId: string, key: string): Promise; + storageSet?(pluginId: string, key: string, value: unknown): Promise; + storageDelete?(pluginId: string, key: string): Promise; } 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); diff --git a/crates/dbx-plugin-runtime/src/plugins/manifest.rs b/crates/dbx-plugin-runtime/src/plugins/manifest.rs index 036a47ed19..4bd428e497 100644 --- a/crates/dbx-plugin-runtime/src/plugins/manifest.rs +++ b/crates/dbx-plugin-runtime/src/plugins/manifest.rs @@ -22,7 +22,7 @@ pub const PLUGIN_CONNECTION_CONNECT_METHOD: &str = "connection/connect"; pub const PLUGIN_CONNECTION_DISCONNECT_METHOD: &str = "connection/disconnect"; pub const PLUGIN_CONNECTION_ACTION_METHOD: &str = "connection/action"; pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = - &["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read"]; + &["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage"]; /// Cap the number of `host.network:` entries so a manifest cannot bloat /// the sandbox CSP or enumerate large origin lists. diff --git a/docs/content/docs/plugin-development.cn.mdx b/docs/content/docs/plugin-development.cn.mdx index c137d93a65..57bdec7e50 100644 --- a/docs/content/docs/plugin-development.cn.mdx +++ b/docs/content/docs/plugin-development.cn.mdx @@ -103,6 +103,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 "host.filesystem", "host.binary", "host.plans:read", + "host.storage", "host.network:https://s3.example.com:443" ], "entrypoints": { @@ -119,6 +120,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含 - `engines.dbx` 是可选的 DBX 版本范围;`engines.host_api` 是必需的 Host API 版本声明。 - `host.workbench` 允许打开另一个工作台,`host.events` 接收宿主事件,`host.binary` 使用二进制通道,`host.filesystem` 使用文件系统入口。 - `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划(见 [估算执行计划](#估算执行计划))。它是只读权限:不授予普通 SQL 执行、写操作、DDL 或真实执行计划,且这是唯一存在的计划类权限。 +- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的数据目录里。 - `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。 - `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。 - `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。 @@ -295,8 +297,9 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `openFilesystem(id, context)` | 打开本插件的文件系统入口;需要 `host.filesystem`。 | | `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 | -| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | +| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, storage }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 | | `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 | +| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 | | `fileTransfer` | 本地文件流式传输与系统拖放(仅桌面端);见下文。 | 调用失败会以 Promise rejection 返回,处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准,不要调用未在协议中声明的 DBX 内部函数。 @@ -392,6 +395,27 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); 仅桌面端可用:Web 宿主下整个命名空间是 `undefined`,使用前请先探测。`onDrop` 只在拖放落点位于本插件工作台区域内时触发;落点在宿主其他区域时保持宿主自身的行为。 +### 持久化 UI 状态 + +沙箱是 opaque origin,`localStorage` 在里面会直接抛错。需要跨重载或跨应用重启保留状态的工作台(最近访问的目录、面板尺寸、草稿等)改用 `window.dbxPlugin.storage`。它需要 `host.storage` 权限,并以 init 消息里的 `capabilities.storage` 广播;使用前请同时探测这两者。 + +```js +if (window.dbxPlugin.capabilities.storage) { + const storage = window.dbxPlugin.storage; + await storage.set("layout", { sidebar: "collapsed", sort: "name" }); + const layout = await storage.get("layout"); // { sidebar: "collapsed", sort: "name" },未设置时为 null + await storage.delete("layout"); +} +``` + +| API | 用途 | +| --- | --- | +| `get(key)` | 返回存储的 JSON 值;键未设置时返回 `null`。 | +| `set(key, value)` | 持久化任意 JSON 值(`undefined` 归一化为 `null`)。 | +| `delete(key)` | 删除一个键;删除不存在的键同样成功。 | + +键为不超过 256 字符的字符串;单个值序列化后上限 256 KiB,整库上限 1 MiB——这是 UI 状态存储,不是数据落盘通道。数据保存在插件自己的 `plugin-data/` 目录里,按插件隔离,版本升级和卸载后都保留。大批量数据、缓存以及 Sidecar 产生的任何文件请放进 Sidecar 的数据目录(见 [原生 Sidecar 和 RPC](#原生-sidecar-和-rpc))。Web 宿主上同一 API 由浏览器本地存储支撑,两端代码完全一致。 + ### 主题、布局和静态资源 DBX 将主题 tokens 注入文档根元素,并更新 `data-dbx-theme`。自定义组件库(包括 Svelte、shadcn-svelte)应使用这些变量,不要依赖父页面 CSS 自动穿透 iframe: @@ -471,6 +495,8 @@ fn main() -> std::io::Result<()> { Sidecar 约束:stdout 只能输出协议消息,日志写 stderr;请求 ID 必须支持并发关联;连接和断开应幂等;长任务应设计超时、取消和分块确认;不要在事件、context 或错误消息中泄露 Secret。 +每个 Sidecar 启动时都会带上 `DBX_PLUGIN_DATA_DIR` 环境变量,指向为本插件保留的持久化目录(`plugin-data/`,与安装包目录平级)。它在版本升级和卸载后都保留,插件的所有 Sidecar 运行共享;缓存、Token、后端管理的数据库等应放在这里——首次写入前需自行创建目录,宿主不会预创建。Go SDK 提供了 `dbxpluginsdk.DataDir()` / `EnsureDataDir()` 封装。工作台 UI 需要小体量持久化时走同一目录里的 `host.storage`(见 [持久化 UI 状态](#持久化-ui-状态)),不要直接读写这些文件。 + 上例可替换 Rust 模板的 `backend/src/main.rs`,同时让 Cargo 包版本与 Manifest 一致。Go 使用模板生成的 `dbxpluginsdk.NewServer(metadata, handler).Serve()`;当前 Go SDK 支持 JSONL,Rust SDK 还支持 framed transport。不要仅修改 Manifest transport 就认为 Go 已具备二进制帧处理能力。完整接口见 [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) 和 [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk)。 ## 创建插件 diff --git a/docs/content/docs/plugin-development.mdx b/docs/content/docs/plugin-development.mdx index 33e37559a0..df361f19da 100644 --- a/docs/content/docs/plugin-development.mdx +++ b/docs/content/docs/plugin-development.mdx @@ -103,6 +103,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con "host.filesystem", "host.binary", "host.plans:read", + "host.storage", "host.network:https://s3.example.com:443" ], "entrypoints": { @@ -119,6 +120,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con - `engines.dbx` is an optional DBX version range; `engines.host_api` is required. - `host.workbench` opens another workbench, `host.events` receives host events, `host.binary` enables binary channels, and `host.filesystem` enables filesystem entrypoints. - `host.plans:read` allows reading estimated execution plans for DBX's own connections (see [Estimated Execution Plans](#estimated-execution-plans)). It is read-only: it never grants SQL execution, writes, DDL, or actual plans, and it is the only plan scope that exists. +- `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory. - `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall. - `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well. - `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target. @@ -295,8 +297,9 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg"); | `openFilesystem(id, context)` | Opens a filesystem entry; requires `host.filesystem`. | | `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. | | `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. | -| `capabilities` | `{ downloadFile, planApi }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. | +| `capabilities` | `{ downloadFile, planApi, storage }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. | | `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. | +| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. | | `fileTransfer` | Streams local files and OS drops (desktop only); see below. | Failed calls reject their Promise; show a useful error and offer retry. Host methods, parameters, and results are versioned API surface—do not call undocumented DBX internals. @@ -392,6 +395,27 @@ const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active)); Desktop only: the whole namespace is `undefined` in the web host, so feature-detect before use. `onDrop` only fires for drops that land inside this plugin's workbench area; drops elsewhere keep the host's own behavior. +### Persistent UI State + +The sandbox has an opaque origin, so `localStorage` throws there. Workbenches that need to survive a reload or an app restart — last visited folder, panel sizes, drafts — use `window.dbxPlugin.storage` instead. It requires the `host.storage` permission and is advertised as `capabilities.storage` in the init message; gate on both before use. + +```js +if (window.dbxPlugin.capabilities.storage) { + const storage = window.dbxPlugin.storage; + await storage.set("layout", { sidebar: "collapsed", sort: "name" }); + const layout = await storage.get("layout"); // { sidebar: "collapsed", sort: "name" }, or null when unset + await storage.delete("layout"); +} +``` + +| API | Purpose | +| --- | --- | +| `get(key)` | Resolves the stored JSON value, or `null` when the key is unset. | +| `set(key, value)` | Persists any JSON value (`undefined` normalizes to `null`). | +| `delete(key)` | Removes one key; deleting an unset key succeeds. | + +Keys are strings up to 256 characters; values are capped at 256 KiB serialized and the whole store at 1 MiB — this is UI state, not a data sink. Entries live in the plugin's own `plugin-data/` directory, isolated per plugin, and survive upgrades and uninstall. Bulk data, caches, and anything the sidecar produces belong in the sidecar's data directory instead (see [Native Sidecars and RPC](#native-sidecars-and-rpc)). On the web host the same API is backed by the browser profile, so identical code works in both hosts. + ### Themes, Layout, and Static Assets DBX injects theme tokens on the document root and updates `data-dbx-theme`. Custom libraries, including Svelte and shadcn-svelte, should consume these variables; parent-page CSS does not cross the iframe: @@ -471,6 +495,8 @@ fn main() -> std::io::Result<()> { Sidecar rules: reserve stdout for protocol messages and write logs to stderr; correlate concurrent requests by ID; make connect/disconnect idempotent; design timeouts, cancellation, and chunk acknowledgements for long tasks; never expose secrets through events, context, or error messages. +Every sidecar starts with `DBX_PLUGIN_DATA_DIR` set to a persistent directory reserved for this plugin (`plugin-data/`, a sibling of the installed package). It survives version upgrades and uninstall, is shared by all of the plugin's sidecar runs, and is the right home for caches, tokens, and databases the backend manages — create it before the first write; the host does not pre-create it. The Go SDK exposes it as `dbxpluginsdk.DataDir()` / `EnsureDataDir()`. Workbench UIs reach a small JSON-backed store inside the same directory through `host.storage` instead of touching these files (see [Persistent UI State](#persistent-ui-state)). + The example can replace the Rust template's `backend/src/main.rs`; keep its Cargo package version equal to the Manifest. Go templates use `dbxpluginsdk.NewServer(metadata, handler).Serve()`. The current Go SDK supports JSONL; the Rust SDK also supports framed transport. Changing a Go Manifest's transport alone does not implement binary framing. See the [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) and [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk) for complete interfaces. ## Create a Plugin diff --git a/plugins/README.md b/plugins/README.md index aae7766cad..521618bddd 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -371,7 +371,8 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san - `openWorkbench(contributionId, context)` — requires `host.workbench` - `openFilesystem(providerId, context)` — requires `host.filesystem` - `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans) -- `capabilities` — `{ downloadFile, planApi }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request +- `storage` — `storage.get(key)` / `storage.set(key, value)` / `storage.delete(key)` persist small JSON state per plugin in `plugin-data/`; requires `host.storage`; values cap at 256 KiB and the whole store at 1 MiB, bulk data belongs in the sidecar's `DBX_PLUGIN_DATA_DIR` +- `capabilities` — `{ downloadFile, planApi, storage }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request - `onEvent(listener)` — events are forwarded only with `host.events` - `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }` @@ -666,7 +667,7 @@ Sidecars are shared per plugin process, not spawned per tab. Plugins own their i - **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding. - **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized. - **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust. -- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. +- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/` directory; it grants no other filesystem reach. Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package. diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json index d963513dae..ab82c20318 100644 --- a/plugins/manifest.schema.json +++ b/plugins/manifest.schema.json @@ -30,7 +30,7 @@ "uniqueItems": true, "items": { "anyOf": [ - { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read"] }, + { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.storage"] }, { "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" } ] } diff --git a/plugins/sdk/dev-host/browser-bridge.mjs b/plugins/sdk/dev-host/browser-bridge.mjs index fec76eec03..ea3dff546d 100644 --- a/plugins/sdk/dev-host/browser-bridge.mjs +++ b/plugins/sdk/dev-host/browser-bridge.mjs @@ -125,6 +125,11 @@ function installBridge(channel) { openFilesystem: (providerId, context) => request("host.openFilesystem", { providerId, context }), reopenConnection: (connectionId) => request("host.reopenConnection", { connectionId }), copy: (text) => request("host.copy", { text }), + storage: { + get: (key) => request("host.storageGet", { key }), + set: (key, value) => request("host.storageSet", { key, value: value === undefined ? null : value }), + delete: (key) => request("host.storageDelete", { key }), + }, onContext: (fn) => listen("context", fn), onEvent: (fn) => listen("event", fn), onBinary: (fn) => listen("binary", fn), diff --git a/plugins/sdk/dev-host/server.mjs b/plugins/sdk/dev-host/server.mjs index e76ee363f8..9936e73093 100644 --- a/plugins/sdk/dev-host/server.mjs +++ b/plugins/sdk/dev-host/server.mjs @@ -11,7 +11,9 @@ import { Diagnostics } from "./diagnostics.mjs"; import { AutoReload } from "./auto-reload.mjs"; const BRIDGE_LIMIT = 2 * 1024 * 1024, - UI_BINARY_LIMIT = 8 * 1024 * 1024; + UI_BINARY_LIMIT = 8 * 1024 * 1024, + STORAGE_VALUE_LIMIT = 256 * 1024, + STORAGE_TOTAL_LIMIT = 1024 * 1024; function jsonSize(value) { return Buffer.byteLength(JSON.stringify(value) ?? "null"); } @@ -103,6 +105,26 @@ export async function createMockHost(options) { await rename(temporary, settingsFile); }; let autoReload = (await readSettings()).autoReload === true; + // `host.storage` entries mirror the native host's plugin-data store: a JSON + // map in the dev data dir, atomically replaced on every write. Serialized on + // the same queue so two frames cannot lose each other's updates. + const uiStorageFile = join(settingsDirectory, "ui-storage.json"); + const readUiStorage = async () => { + try { + const parsed = JSON.parse(await readFile(uiStorageFile, "utf8")); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}; + } catch { + return {}; + } + }; + const writeUiStorage = async (entries) => { + const encoded = JSON.stringify(entries); + if (Buffer.byteLength(encoded) > STORAGE_TOTAL_LIMIT) throw new Error(`Plugin UI storage exceeds ${STORAGE_TOTAL_LIMIT} bytes`); + await mkdir(settingsDirectory, { recursive: true, mode: 0o700 }); + const temporary = join(settingsDirectory, `.ui-storage-${randomUUID()}.tmp`); + await writeFile(temporary, encoded, { mode: 0o600, flag: "wx" }); + await rename(temporary, uiStorageFile); + }; const backendReload = new AutoReload(async () => { try { await serialize(async () => { @@ -245,6 +267,26 @@ export async function createMockHost(options) { if (typeof p.connectionId !== "string" || !p.connectionId) throw new Error("connectionId is invalid"); return { ok: true, mockReopenConnection: p.connectionId }; } + case "host.storageGet": + case "host.storageSet": + case "host.storageDelete": { + requirePermission(manifest, "host.storage"); + if (typeof p.key !== "string" || !p.key || p.key.length > 256 || /[\u0000-\u001f]/.test(p.key)) throw new Error("storage key is invalid"); + return serialize(async () => { + const entries = await readUiStorage(); + if (input.method === "host.storageGet") return entries[p.key] === undefined ? null : structuredClone(entries[p.key]); + if (input.method === "host.storageDelete") { + delete entries[p.key]; + await writeUiStorage(entries); + return null; + } + const value = p.value === undefined ? null : p.value; + if (Buffer.byteLength(JSON.stringify(value)) > STORAGE_VALUE_LIMIT) throw new Error(`storage value exceeds ${STORAGE_VALUE_LIMIT} bytes`); + entries[p.key] = value; + await writeUiStorage(entries); + return null; + }); + } default: throw new Error(`Unsupported mock host method: ${input.method}`); } diff --git a/plugins/sdk/dev-host/tests/server.test.mjs b/plugins/sdk/dev-host/tests/server.test.mjs index 6b7d906520..c07d14039b 100644 --- a/plugins/sdk/dev-host/tests/server.test.mjs +++ b/plugins/sdk/dev-host/tests/server.test.mjs @@ -371,6 +371,38 @@ test("save, reload, iframe isolation, generic RPC and close lifecycle", async (t assert.equal(failedBuild.status, 400); assert.equal(host.sidecar.state, "stopped"); }); +test("host.storage persists per-plugin entries behind the declared permission", async (t) => { + const { root, request } = await fixture(t); + const saved = await request("connections/save", { providerId: "example.connection", values }); + const frame = (await request("connections/connect", { id: saved.value.id })).value.frame; + const document = (await request("frame-document", { frameId: frame.id })).value; + const call = (method, params) => request("bridge", { frameId: frame.id, channel: document.channel, method, params }); + + assert.equal((await call("host.storageGet", { key: "theme" })).status, 400, "permission must gate storage"); + + const { request: allowedRequest, root: allowedRoot } = await fixture(t, false, { + pluginManifest: { ...manifest, permissions: [...manifest.permissions, "host.storage"] }, + }); + const allowedSave = await allowedRequest("connections/save", { providerId: "example.connection", values }); + const allowedFrame = (await allowedRequest("connections/connect", { id: allowedSave.value.id })).value.frame; + const allowedDocument = (await allowedRequest("frame-document", { frameId: allowedFrame.id })).value; + const storage = (method, params) => allowedRequest("bridge", { frameId: allowedFrame.id, channel: allowedDocument.channel, method, params }); + + assert.equal((await storage("host.storageGet", { key: "theme" })).value, null); + assert.equal((await storage("host.storageSet", { key: "theme", value: { mode: "dark", tabs: [1, 2] } })).status, 200); + assert.deepEqual((await storage("host.storageGet", { key: "theme" })).value, { mode: "dark", tabs: [1, 2] }); + assert.equal((await storage("host.storageSet", { key: "theme", value: undefined })).status, 200); + assert.equal((await storage("host.storageGet", { key: "theme" })).value, null, "undefined normalizes to null"); + assert.equal((await storage("host.storageSet", { key: "blob", value: "x".repeat(256 * 1024 + 1) })).status, 400, "oversized values are rejected"); + assert.equal((await storage("host.storageGet", { key: "" })).status, 400, "empty keys are rejected"); + assert.equal((await storage("host.storageSet", { key: "last", value: "keep" })).status, 200); + assert.equal((await storage("host.storageDelete", { key: "last" })).status, 200); + assert.equal((await storage("host.storageGet", { key: "last" })).value, null); + + const persisted = JSON.parse(await readFile(join(allowedRoot, "data/ui-storage.json"), "utf8")); + assert.deepEqual(persisted, { theme: null }); + if (process.platform !== "win32") assert.equal((await stat(join(allowedRoot, "data/ui-storage.json"))).mode & 0o777, 0o600); +}); test("loopback endpoint rejects cross-origin, forged Host, CSRF and unowned frames", async (t) => { const { host, request, headers } = await fixture(t); assert.equal((await request("connections/save", {}, { Origin: "https://evil.example" })).status, 403); diff --git a/plugins/sdk/go/dbx-plugin-sdk/sdk.go b/plugins/sdk/go/dbx-plugin-sdk/sdk.go index fd26517396..b27a4d2506 100644 --- a/plugins/sdk/go/dbx-plugin-sdk/sdk.go +++ b/plugins/sdk/go/dbx-plugin-sdk/sdk.go @@ -409,3 +409,28 @@ const ( frameKindJSON = 0 frameKindBinary = 1 ) + +// DataDirEnvVar is the environment variable the host sets on every sidecar, +// pointing at this plugin's persistent data directory. +const DataDirEnvVar = "DBX_PLUGIN_DATA_DIR" + +// DataDir returns the plugin's persistent data directory from the host +// environment. It returns "" when the plugin does not run under a host that +// provides one (unit tests, standalone binaries). +func DataDir() string { + return os.Getenv(DataDirEnvVar) +} + +// EnsureDataDir returns the plugin's persistent data directory, creating it if +// needed, so the first write does not have to mkdir first. It returns an error +// when no directory was provided by the host. +func EnsureDataDir() (string, error) { + dir := DataDir() + if dir == "" { + return "", errors.New("plugin data directory is not set; running outside a DBX host") + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + return dir, nil +} diff --git a/plugins/sdk/go/dbx-plugin-sdk/sdk_test.go b/plugins/sdk/go/dbx-plugin-sdk/sdk_test.go index 6d4a201f2c..2122109b30 100644 --- a/plugins/sdk/go/dbx-plugin-sdk/sdk_test.go +++ b/plugins/sdk/go/dbx-plugin-sdk/sdk_test.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/binary" "encoding/json" + "os" + "path/filepath" "sync" "testing" ) @@ -106,3 +108,34 @@ func TestFramedServerDispatchesBinaryInput(t *testing.T) { t.Fatal("expected framed initialize response") } } + +func TestDataDirHelpers(t *testing.T) { + t.Setenv(DataDirEnvVar, "") + if DataDir() != "" { + t.Fatal("expected empty data dir without host environment") + } + if _, err := EnsureDataDir(); err == nil { + t.Fatal("expected an error without host environment") + } + + dir := t.TempDir() + t.Setenv(DataDirEnvVar, dir) + if DataDir() != dir { + t.Fatalf("unexpected data dir: %q", DataDir()) + } + if _, err := EnsureDataDir(); err != nil { + t.Fatal(err) + } + deep := filepath.Join(dir, "nested", "state") + t.Setenv(DataDirEnvVar, deep) + created, err := EnsureDataDir() + if err != nil { + t.Fatal(err) + } + if created != deep { + t.Fatalf("unexpected created dir: %q", created) + } + if info, err := os.Stat(deep); err != nil || !info.IsDir() { + t.Fatalf("expected created directory: %v %v", info, err) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 30de1428f2..a7661ac180 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -41,6 +41,7 @@ pub mod nacos_cmd; pub mod plugin_download; pub mod plugin_download_file; pub mod plugin_file; +pub mod plugin_storage; pub mod plugins; pub mod prompt_template; pub mod query; diff --git a/src-tauri/src/commands/plugin_storage.rs b/src-tauri/src/commands/plugin_storage.rs new file mode 100644 index 0000000000..6646b3fd98 --- /dev/null +++ b/src-tauri/src/commands/plugin_storage.rs @@ -0,0 +1,297 @@ +//! Persistent key-value storage for plugin workbench UIs. +//! +//! The workbench sandbox runs with an opaque origin, so `localStorage` and +//! friends throw there and a plugin UI has nowhere of its own to keep state. +//! These commands give every plugin a small JSON-backed store inside its own +//! `plugin-data/` directory — the same tree the sidecar receives as +//! `DBX_PLUGIN_DATA_DIR` — without touching the application database. +//! +//! Every call carries the caller's plugin id; the workbench-host TS layer is +//! the only caller of these commands, and the id selects a per-plugin +//! directory, so one plugin can neither read nor shape another plugin's +//! entries. Values are arbitrary JSON documents; both per-value and per-store +//! size caps keep this a UI-state store — bulk data belongs to the sidecar. + +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use serde_json::Value; +use tauri::State; + +use super::connection::AppState; + +/// File backing one plugin's UI store, inside its `plugin-data/` directory. +const UI_STORAGE_FILE: &str = "ui-storage.json"; +/// Sibling name a corrupt store is moved aside to before starting fresh. +const CORRUPT_SUFFIX: &str = ".corrupt"; +/// Upper bound for one serialized value: UI state, not a blob store. +pub const MAX_PLUGIN_STORAGE_VALUE_BYTES: usize = 256 * 1024; +/// Upper bound for the whole serialized store. +pub const MAX_PLUGIN_STORAGE_TOTAL_BYTES: usize = 1024 * 1024; +/// Upper bound for the number of distinct keys. +pub const MAX_PLUGIN_STORAGE_KEYS: usize = 1024; +const MAX_PLUGIN_ID_CHARS: usize = 128; +const MAX_STORAGE_KEY_CHARS: usize = 256; + +/// Serializes read-modify-write cycles across calls. Tauri runs async commands +/// on a thread pool, so two workbench tabs of one plugin could otherwise race +/// a set() against another set() and lose an update. +#[derive(Default)] +pub struct PluginUiStorageState { + lock: Arc>, +} + +impl PluginUiStorageState { + pub fn new() -> Self { + Self::default() + } +} + +/// Reject ids that could escape the per-plugin directory. Mirrors the +/// manifest identifier pattern (`^[a-z0-9][a-z0-9._-]*$`): with no path +/// separators and an alphanumeric lead, `join(plugin_id)` is always a single +/// fresh directory component. +fn validate_plugin_id(plugin_id: &str) -> Result<(), String> { + let mut chars = plugin_id.chars(); + match chars.next() { + Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit() => {} + _ => return Err("plugin id is invalid".to_string()), + } + if plugin_id.len() > MAX_PLUGIN_ID_CHARS + || !chars.all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || matches!(character, '.' | '_' | '-') + }) + { + return Err("plugin id is invalid".to_string()); + } + Ok(()) +} + +fn validate_key(key: &str) -> Result<(), String> { + if key.is_empty() || key.chars().count() > MAX_STORAGE_KEY_CHARS || key.chars().any(char::is_control) { + return Err("storage key is invalid".to_string()); + } + Ok(()) +} + +fn storage_file(data_dir: &Path) -> PathBuf { + data_dir.join(UI_STORAGE_FILE) +} + +/// Reads one plugin's entries. A missing file is an empty store; a file that +/// does not parse is moved aside (`.corrupt`, replacing any earlier one) so a +/// truncated write heals on the next call while the damaged bytes stay +/// recoverable by hand instead of being silently destroyed. +fn load_entries(data_dir: &Path) -> Result, String> { + let path = storage_file(data_dir); + let contents = match std::fs::read(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(serde_json::Map::new()), + Err(error) => return Err(format!("cannot read plugin UI storage: {error}")), + }; + match serde_json::from_slice::(&contents) { + Ok(Value::Object(map)) => Ok(map), + _ => { + std::fs::rename(&path, format!("{}{}", path.to_string_lossy(), CORRUPT_SUFFIX)) + .map_err(|error| format!("cannot move corrupt plugin UI storage aside: {error}"))?; + Ok(serde_json::Map::new()) + } + } +} + +/// Atomically replaces the store: write a temp sibling, fsync, rename over the +/// target. A crash mid-write leaves the old store intact. +fn save_entries(data_dir: &Path, entries: &serde_json::Map) -> Result<(), String> { + let bytes = serde_json::to_vec(&Value::Object(entries.clone())) + .map_err(|error| format!("cannot encode plugin UI storage: {error}"))?; + if bytes.len() > MAX_PLUGIN_STORAGE_TOTAL_BYTES { + return Err(format!("plugin UI storage exceeds {MAX_PLUGIN_STORAGE_TOTAL_BYTES} bytes")); + } + std::fs::create_dir_all(data_dir).map_err(|error| format!("cannot create plugin data directory: {error}"))?; + let path = storage_file(data_dir); + let temp = format!("{}.tmp", path.to_string_lossy()); + { + use std::io::Write; + let mut file = std::fs::File::create(&temp) + .map_err(|error| format!("cannot create plugin UI storage temp file: {error}"))?; + file.write_all(&bytes).map_err(|error| format!("cannot write plugin UI storage: {error}"))?; + file.sync_all().map_err(|error| format!("cannot flush plugin UI storage: {error}"))?; + } + std::fs::rename(&temp, &path).map_err(|error| format!("cannot replace plugin UI storage: {error}")) +} + +pub fn get_ui_storage_entry(data_dir: &Path, key: &str) -> Result, String> { + validate_key(key)?; + Ok(load_entries(data_dir)?.get(key).cloned()) +} + +pub fn set_ui_storage_entry(data_dir: &Path, key: &str, value: Value) -> Result<(), String> { + validate_key(key)?; + let encoded = + serde_json::to_vec(&value).map_err(|error| format!("cannot encode plugin UI storage value: {error}"))?; + if encoded.len() > MAX_PLUGIN_STORAGE_VALUE_BYTES { + return Err(format!("plugin UI storage value exceeds {MAX_PLUGIN_STORAGE_VALUE_BYTES} bytes")); + } + let mut entries = load_entries(data_dir)?; + if !entries.contains_key(key) && entries.len() >= MAX_PLUGIN_STORAGE_KEYS { + return Err(format!("plugin UI storage exceeds {MAX_PLUGIN_STORAGE_KEYS} keys")); + } + entries.insert(key.to_string(), value); + save_entries(data_dir, &entries) +} + +pub fn delete_ui_storage_entry(data_dir: &Path, key: &str) -> Result<(), String> { + validate_key(key)?; + let mut entries = load_entries(data_dir)?; + if entries.remove(key).is_some() { + return save_entries(data_dir, &entries); + } + Ok(()) +} + +fn data_dir(registry: &dbx_core::plugins::PluginRegistry, plugin_id: &str) -> Result { + validate_plugin_id(plugin_id)?; + Ok(registry.plugin_data_dir(plugin_id)) +} + +#[tauri::command] +pub async fn plugin_ui_storage_get( + state: State<'_, Arc>, + storage: State<'_, PluginUiStorageState>, + plugin_id: String, + key: String, +) -> Result, String> { + let dir = data_dir(&state.plugins, &plugin_id)?; + let lock = storage.lock.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _guard = lock.lock().map_err(|_| "plugin UI storage lock poisoned".to_string())?; + get_ui_storage_entry(&dir, &key) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn plugin_ui_storage_set( + state: State<'_, Arc>, + storage: State<'_, PluginUiStorageState>, + plugin_id: String, + key: String, + value: Value, +) -> Result<(), String> { + let dir = data_dir(&state.plugins, &plugin_id)?; + let lock = storage.lock.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _guard = lock.lock().map_err(|_| "plugin UI storage lock poisoned".to_string())?; + set_ui_storage_entry(&dir, &key, value) + }) + .await + .map_err(|error| error.to_string())? +} + +#[tauri::command] +pub async fn plugin_ui_storage_delete( + state: State<'_, Arc>, + storage: State<'_, PluginUiStorageState>, + plugin_id: String, + key: String, +) -> Result<(), String> { + let dir = data_dir(&state.plugins, &plugin_id)?; + let lock = storage.lock.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _guard = lock.lock().map_err(|_| "plugin UI storage lock poisoned".to_string())?; + delete_ui_storage_entry(&dir, &key) + }) + .await + .map_err(|error| error.to_string())? +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("dbx-plugin-storage-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("create temp dir"); + dir + } + + #[test] + fn set_get_delete_roundtrip() { + let dir = temp_dir("roundtrip"); + assert_eq!(get_ui_storage_entry(&dir, "theme").unwrap(), None); + set_ui_storage_entry(&dir, "theme", json!({ "mode": "dark", "tabs": [1, 2] })).unwrap(); + assert_eq!(get_ui_storage_entry(&dir, "theme").unwrap(), Some(json!({ "mode": "dark", "tabs": [1, 2] }))); + + set_ui_storage_entry(&dir, "lastPath", json!("s3://bucket/prefix")).unwrap(); + delete_ui_storage_entry(&dir, "theme").unwrap(); + assert_eq!(get_ui_storage_entry(&dir, "theme").unwrap(), None); + assert_eq!(get_ui_storage_entry(&dir, "lastPath").unwrap(), Some(json!("s3://bucket/prefix"))); + + // Deleting the last key leaves a valid empty store behind. + delete_ui_storage_entry(&dir, "lastPath").unwrap(); + let raw: Value = serde_json::from_slice(&std::fs::read(storage_file(&dir)).unwrap()).unwrap(); + assert_eq!(raw, json!({})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn stores_are_isolated_per_directory() { + let root = temp_dir("isolation"); + let one = root.join("io.dbx.one"); + let two = root.join("io.dbx.two"); + set_ui_storage_entry(&one, "secret", json!("one")).unwrap(); + set_ui_storage_entry(&two, "secret", json!("two")).unwrap(); + assert_eq!(get_ui_storage_entry(&one, "secret").unwrap(), Some(json!("one"))); + assert_eq!(get_ui_storage_entry(&two, "secret").unwrap(), Some(json!("two"))); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn corrupt_store_is_moved_aside_and_heals() { + let dir = temp_dir("corrupt"); + std::fs::write(storage_file(&dir), b"{ not json").unwrap(); + assert_eq!(get_ui_storage_entry(&dir, "key").unwrap(), None); + assert!(std::fs::read(format!("{}{}", storage_file(&dir).to_string_lossy(), CORRUPT_SUFFIX)).is_ok()); + set_ui_storage_entry(&dir, "key", json!("fresh")).unwrap(); + assert_eq!(get_ui_storage_entry(&dir, "key").unwrap(), Some(json!("fresh"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_oversized_values_and_total_store() { + let dir = temp_dir("caps"); + let big = "x".repeat(MAX_PLUGIN_STORAGE_VALUE_BYTES + 1); + assert!(set_ui_storage_entry(&dir, "big", json!(big)).is_err()); + + let chunk = "y".repeat(64 * 1024); + // Fifteen chunks stay under the 1 MiB total cap; the sixteenth trips it. + for index in 0..15 { + set_ui_storage_entry(&dir, &format!("chunk{index}"), json!(chunk)).unwrap(); + } + let error = set_ui_storage_entry(&dir, "overflow", json!(chunk)).unwrap_err(); + assert!(error.contains("exceeds"), "{error}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_invalid_keys_and_plugin_ids() { + let dir = temp_dir("validation"); + for key in [String::new(), "x".repeat(257), "bad\u{1}key".to_string()] { + assert!(set_ui_storage_entry(&dir, &key, json!(1)).is_err(), "key {key:?} must be rejected"); + } + assert!(get_ui_storage_entry(&dir, "ok").is_ok()); + + let registry = dbx_core::plugins::PluginRegistry::new(PathBuf::from("/data/plugins")); + for plugin_id in ["", " ", "../escape", "io.dbx/other", ".hidden", "..", "a..b/../c", "UPPER.case", "sp ace"] { + assert!(data_dir(®istry, plugin_id).is_err(), "plugin id {plugin_id:?} must be rejected"); + } + assert_eq!( + data_dir(®istry, "io.github.t8y2.s3").unwrap(), + PathBuf::from("/data/plugin-data/io.github.t8y2.s3") + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d867f073e4..cd40a6407d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1536,6 +1536,7 @@ pub fn run() { builder .manage(CloseBehaviorState::new()) .manage(commands::plugin_file::PluginFileState::new()) + .manage(commands::plugin_storage::PluginUiStorageState::new()) .manage(AppLocaleState::new()) .on_page_load(|webview, payload| { if payload.event() == PageLoadEvent::Started { @@ -1882,6 +1883,9 @@ pub fn run() { commands::plugin_file::plugin_file_read, commands::plugin_file::plugin_file_write, commands::plugin_file::plugin_file_close, + commands::plugin_storage::plugin_ui_storage_get, + commands::plugin_storage::plugin_ui_storage_set, + commands::plugin_storage::plugin_ui_storage_delete, commands::plugins::list_plugins, commands::plugins::list_plugin_trusted_keys, commands::plugins::save_plugin_trusted_key,