From c8f8282d8309aefe12599c19d3b6d5172da4ce44 Mon Sep 17 00:00:00 2001 From: Harsh Vardhan Chauhan Date: Sun, 11 Oct 2026 23:21:31 +0530 Subject: [PATCH 01/10] feat: sandbox plugin JavaScript with CSP --- package.json | 2 + packages/explain/README.md | 9 +- packages/explain/src/parsers/source.ts | 35 +-- packages/explain/src/raw.ts | 8 +- packages/explain/src/registry.ts | 4 +- .../explain/tests/parsers/postgres.test.ts | 38 +-- packages/explain/tests/parsers/source.test.ts | 56 ++-- packages/explain/tests/raw.test.ts | 36 +-- packages/explain/tests/registry.test.ts | 38 +-- packages/plugin-api/README.md | 17 +- packages/plugin-api/src/types.ts | 4 +- packages/plugin-api/src/version.ts | 4 +- packages/plugin-guest/build.mjs | 29 ++ packages/plugin-guest/src/runtime.ts | 290 ++++++++++++++++++ plugins/EXPLAIN_SANDBOX_DESIGN.md | 36 +++ plugins/PLUGIN_GUIDE.md | 24 +- plugins/UI_SANDBOX_DESIGN.md | 39 +++ pnpm-lock.yaml | 3 + src-tauri/resources/guest/__guest__.html | 11 + src-tauri/resources/guest/host.css | 2 + src-tauri/resources/guest/runtime.js | 14 + src-tauri/src/backup/mod.rs | 3 +- src-tauri/src/commands.rs | 48 ++- src-tauri/src/lib.rs | 3 + src-tauri/src/plugins/commands.rs | 11 +- src-tauri/src/plugins/mod.rs | 1 + src-tauri/src/plugins/sandbox_protocol.rs | 134 ++++++++ src-tauri/tauri.conf.json | 20 +- src/components/modals/AiApprovalModal.tsx | 14 +- src/contexts/PluginSlotProvider.tsx | 110 ++----- src/i18n/locales/de.json | 6 + src/i18n/locales/en.json | 6 + src/i18n/locales/es.json | 6 + src/i18n/locales/fr.json | 6 + src/i18n/locales/it.json | 6 + src/i18n/locales/ja.json | 6 + src/i18n/locales/ko.json | 6 + src/i18n/locales/pt-BR.json | 6 + src/i18n/locales/ru.json | 6 + src/i18n/locales/tl.json | 6 + src/i18n/locales/zh.json | 6 + src/pages/Connections.tsx | 5 + src/pages/VisualExplainPage.tsx | 2 +- src/plugins/sandbox/SandboxedPluginFrame.tsx | 158 ++++++++++ src/plugins/sandbox/connectFrame.ts | 49 +++ src/plugins/sandbox/rpc.ts | 64 ++++ src/utils/approvalExplain.ts | 4 +- src/utils/pluginExplainLoader.ts | 222 +++++++++----- .../modals/VisualExplainModal.test.tsx | 6 +- .../notebook/NotebookCellWrapper.test.tsx | 6 +- .../notebook/SqlCellExplain.test.tsx | 6 +- tests/hooks/useExplainPlan.test.ts | 6 +- tests/pages/VisualExplainPage.test.tsx | 16 +- .../plugins/pluginIsolationAcceptance.test.ts | 134 ++++++++ tests/setup.ts | 1 + tests/utils/approvalExplain.test.ts | 30 +- tests/utils/pluginExplainLoader.test.ts | 248 ++++++--------- 57 files changed, 1560 insertions(+), 506 deletions(-) create mode 100644 packages/plugin-guest/build.mjs create mode 100644 packages/plugin-guest/src/runtime.ts create mode 100644 plugins/EXPLAIN_SANDBOX_DESIGN.md create mode 100644 plugins/UI_SANDBOX_DESIGN.md create mode 100644 src-tauri/resources/guest/__guest__.html create mode 100644 src-tauri/resources/guest/host.css create mode 100644 src-tauri/resources/guest/runtime.js create mode 100644 src-tauri/src/plugins/sandbox_protocol.rs create mode 100644 src/plugins/sandbox/SandboxedPluginFrame.tsx create mode 100644 src/plugins/sandbox/connectFrame.ts create mode 100644 src/plugins/sandbox/rpc.ts create mode 100644 tests/plugins/pluginIsolationAcceptance.test.ts diff --git a/package.json b/package.json index 52319afd6..542326f5c 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "build:plugin-guest": "node packages/plugin-guest/build.mjs", "lint": "eslint . && node scripts/check-theme-tokens.mjs", "lint:theme": "node scripts/check-theme-tokens.mjs", "test:contrast": "vitest run tests/utils/themeContrast.test.ts", @@ -95,6 +96,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.26", + "esbuild": "^0.27.4", "gitnexus": "^1.6.9", "globals": "^16.5.0", "jsdom": "^28.1.0", diff --git a/packages/explain/README.md b/packages/explain/README.md index 8065608bf..cf8242c04 100644 --- a/packages/explain/README.md +++ b/packages/explain/README.md @@ -53,13 +53,13 @@ SQLite `EXPLAIN QUERY PLAN` rows: import { parseExplain, parseExplainFor, parseRawExplain } from "@tabularis/explain"; // Sniff the format of a pasted blob or an uploaded file: -const plan = parseExplain(raw); +const plan = await parseExplain(raw); // Or name the engine when the caller knows it: -const hinted = parseExplainFor(raw, "mysql"); +const hinted = await parseExplainFor(raw, "mysql"); // Or parse the raw payload a host driver handed over: -const fromDriver = parseRawExplain({ engine, format, payload, original_query }); +const fromDriver = await parseRawExplain({ engine, format, payload, original_query }); ``` A host obtains the bytes however it likes — a driver, a pasted textarea, an @@ -86,6 +86,9 @@ registerExplainParser({ ``` Import the parser package before calling `parseRawExplain` or source detection. +These helpers are asynchronous because plugin parsers run in isolated guest +frames. Await `detectFormat`, `detectFormatFor`, `parseExplain`, +`parseExplainFor`, `parseRawExplain`, and `resolveExplainOutput`. Registering the same format again replaces it, which supports plugin upgrades; call `unregisterExplainParser(format)` when unloading it. diff --git a/packages/explain/src/parsers/source.ts b/packages/explain/src/parsers/source.ts index baf7a7fcc..711b69f8f 100644 --- a/packages/explain/src/parsers/source.ts +++ b/packages/explain/src/parsers/source.ts @@ -97,8 +97,8 @@ export function explainEngineFromDriverName(name: string): ExplainEngine | null * * Recognises the two Postgres shapes before trying registered custom sniffers. */ -export function detectFormat(raw: string): ExplainSourceFormat { - return detectFormatFor(raw, null); +export async function detectFormat(raw: string): Promise { + return await detectFormatFor(raw, null); } /** @@ -108,19 +108,19 @@ export function detectFormat(raw: string): ExplainSourceFormat { * Built-in hints retain their historical decisions. Custom parsers are tried * in registry order and only through their side-effect-free sniffers. */ -export function detectFormatFor( +export async function detectFormatFor( raw: string, engine: ExplainEngine | null, -): ExplainSourceFormat { +): Promise { switch (engine) { case "postgres": - return detectPostgresFormat(raw); + return Promise.resolve(detectPostgresFormat(raw)); case "mysql": - if (looksLikeJson(raw)) return "mysql-json"; + if (looksLikeJson(raw)) return Promise.resolve("mysql-json"); if (raw.trim() === "") { throw new Error("Unsupported EXPLAIN file format: input is empty"); } - return "mysql-text"; + return Promise.resolve("mysql-text"); case "sqlite": throw new Error( "SQLite EXPLAIN QUERY PLAN has no text form here: pass its " + @@ -129,16 +129,14 @@ export function detectFormatFor( case null: { const builtinFormat = detectPostgresFormatOrNull(raw); if (builtinFormat !== null) return builtinFormat; - - const customFormat = sniffRegisteredFormat(raw, null); + const customFormat = await sniffRegisteredFormat(raw, null); if (customFormat !== null) return customFormat; - throw new Error( "Unsupported EXPLAIN file format: expected Postgres JSON or text output", ); } default: { - const format = sniffRegisteredFormat(raw, engine); + const format = await sniffRegisteredFormat(raw, engine); if (format !== null) return format; throw new Error(`Unsupported EXPLAIN file format for engine '${engine}'`); } @@ -161,10 +159,10 @@ function detectPostgresFormatOrNull( return null; } -function sniffRegisteredFormat( +async function sniffRegisteredFormat( raw: string, engine: ExplainEngine | null, -): ExplainSourceFormat | null { +): Promise { const normalizedEngine = engine?.trim().toLowerCase() ?? null; for (const parser of listExplainParsers()) { @@ -180,7 +178,7 @@ function sniffRegisteredFormat( if (parser.sniff === undefined) continue; try { - if (parser.sniff(raw)) return parser.format; + if (await parser.sniff(raw)) return parser.format; } catch { // A sniffer is advisory; one broken parser must not block later parsers. } @@ -206,16 +204,17 @@ function looksLikePostgresText(raw: string): boolean { * * Equivalent to `parseExplainFor(raw, null)`. */ -export function parseExplain(raw: string): ExplainPlan { +export function parseExplain(raw: string): Promise { return parseExplainFor(raw, null); } /** Parse a payload, using the caller's engine hint when there is one. */ -export function parseExplainFor( +export async function parseExplainFor( raw: string, engine: ExplainEngine | null, -): ExplainPlan { - return parserFor(detectFormatFor(raw, engine)).parse(raw); +): Promise { + const format = await detectFormatFor(raw, engine); + return await parserFor(format).parse(raw); } /** diff --git a/packages/explain/src/raw.ts b/packages/explain/src/raw.ts index e2ba998c1..8d40e9083 100644 --- a/packages/explain/src/raw.ts +++ b/packages/explain/src/raw.ts @@ -44,7 +44,7 @@ export type ExplainQueryOutput = | { kind: "plan"; plan: ExplainPlan }; /** Parse a driver's raw EXPLAIN payload into a plan. */ -export function parseRawExplain(raw: RawExplainOutput): ExplainPlan { +export async function parseRawExplain(raw: RawExplainOutput): Promise { const parser = getExplainParser(raw.format); if (parser === null) { throw new Error( @@ -53,7 +53,7 @@ export function parseRawExplain(raw: RawExplainOutput): ExplainPlan { ); } - const plan = parser.parse(raw.payload); + const plan = await parser.parse(raw.payload); return { ...plan, driver: raw.engine, @@ -62,6 +62,6 @@ export function parseRawExplain(raw: RawExplainOutput): ExplainPlan { } /** Normalise either shape of `ExplainQueryOutput` into a plan. */ -export function resolveExplainOutput(output: ExplainQueryOutput): ExplainPlan { - return output.kind === "plan" ? output.plan : parseRawExplain(output.raw); +export function resolveExplainOutput(output: ExplainQueryOutput): Promise { + return output.kind === "plan" ? Promise.resolve(output.plan) : parseRawExplain(output.raw); } diff --git a/packages/explain/src/registry.ts b/packages/explain/src/registry.ts index b5ac7d070..0d4ef9a54 100644 --- a/packages/explain/src/registry.ts +++ b/packages/explain/src/registry.ts @@ -9,9 +9,9 @@ export interface RegisteredExplainParser { /** Human label for format pickers. */ readonly label?: string; /** Parse the raw payload or throw an Error. */ - parse(payload: string): ExplainPlan; + parse(payload: string): ExplainPlan | Promise; /** Cheap, side-effect-free source detection. */ - sniff?(payload: string): boolean; + sniff?(payload: string): boolean | Promise; } const builtinParsersByFormat = new Map( diff --git a/packages/explain/tests/parsers/postgres.test.ts b/packages/explain/tests/parsers/postgres.test.ts index be09005e4..00ceeffe2 100644 --- a/packages/explain/tests/parsers/postgres.test.ts +++ b/packages/explain/tests/parsers/postgres.test.ts @@ -48,22 +48,22 @@ const POSTGRES_NESTED = `[ describe("postgres parsers", () => { describe("detectFormat", () => { - it("accepts a JSON array", () => { - expect(detectFormat(" \n [\n {} ]")).toBe("postgres-json"); + it("accepts a JSON array", async () => { + expect(await detectFormat(" \n [\n {} ]")).toBe("postgres-json"); }); - it("accepts a JSON object", () => { - expect(detectFormat('{ "Plan": {} }')).toBe("postgres-json"); + it("accepts a JSON object", async () => { + expect(await detectFormat('{ "Plan": {} }')).toBe("postgres-json"); }); - it("rejects plain text without a cost header", () => { - expect(() => + it("rejects plain text without a cost header", async () => { + await expect( detectFormat("Seq Scan on users (cost=0.00..12.34 rows=100)"), - ).toThrow(/Unsupported/); + ).rejects.toThrow(/Unsupported/); }); - it("recognises text output with a cost header", () => { - expect(detectFormat(POSTGRES_TEXT_FLAT)).toBe("postgres-text"); + it("recognises text output with a cost header", async () => { + expect(await detectFormat(POSTGRES_TEXT_FLAT)).toBe("postgres-text"); }); }); @@ -112,20 +112,20 @@ describe("postgres parsers", () => { }); describe("parseExplain error paths", () => { - it("rejects an empty array", () => { - expect(() => parseExplain("[]")).toThrow(/empty/); + it("rejects an empty array", async () => { + await expect(parseExplain("[]")).rejects.toThrow(/empty/); }); - it("rejects a missing Plan key", () => { - expect(() => parseExplain('[{"NotAPlan": 1}]')).toThrow(/Plan/); + it("rejects a missing Plan key", async () => { + await expect(parseExplain('[{"NotAPlan": 1}]')).rejects.toThrow(/Plan/); }); - it("rejects invalid JSON", () => { - expect(() => parseExplain("[not json]")).toThrow(/Failed to parse/); + it("rejects invalid JSON", async () => { + await expect(parseExplain("[not json]")).rejects.toThrow(/Failed to parse/); }); - it("rejects an unsupported format", () => { - expect(() => parseExplain("-> Nested Loop (cost=0.00..1.23)")).toThrow( + it("rejects an unsupported format", async () => { + await expect(parseExplain("-> Nested Loop (cost=0.00..1.23)")).rejects.toThrow( /Unsupported/, ); }); @@ -192,8 +192,8 @@ describe("postgres parsers", () => { }); describe("parseExplain routing", () => { - it("routes text output to the text parser", () => { - const plan = parseExplain(POSTGRES_TEXT_FLAT); + it("routes text output to the text parser", async () => { + const plan = await parseExplain(POSTGRES_TEXT_FLAT); expect(plan.root.node_type).toBe("Seq Scan"); }); }); diff --git a/packages/explain/tests/parsers/source.test.ts b/packages/explain/tests/parsers/source.test.ts index 0e5e29711..27420eaa2 100644 --- a/packages/explain/tests/parsers/source.test.ts +++ b/packages/explain/tests/parsers/source.test.ts @@ -44,8 +44,8 @@ const POSTGRES_TEXT = " Seq Scan on users (cost=0.00..12.34 rows=100 width=80)\ describe("source dispatch", () => { describe("an engine hint reaches parsers that sniffing cannot", () => { - it("parses MySQL JSON when the engine is given", () => { - const plan = parseExplainFor(MYSQL_JSON, "mysql"); + it("parses MySQL JSON when the engine is given", async () => { + const plan = await parseExplainFor(MYSQL_JSON, "mysql"); expect(plan.driver).toBe("mysql"); expect(plan.root.relation).toBe("users"); @@ -54,60 +54,60 @@ describe("source dispatch", () => { expect(plan.raw_output).not.toBeNull(); }); - it("keeps MariaDB optimizer time as planning time", () => { - const plan = parseExplainFor(MARIADB_ANALYZE_JSON, "mysql"); + it("keeps MariaDB optimizer time as planning time", async () => { + const plan = await parseExplainFor(MARIADB_ANALYZE_JSON, "mysql"); expect(plan.planning_time_ms).toBe(1.875); expect(plan.has_analyze_data, "r_* fields are actual data").toBe(true); expect(plan.root.relation).toBe("orders"); }); - it("parses MySQL text when the engine is given", () => { - const plan = parseExplainFor(MYSQL_TEXT, "mysql"); + it("parses MySQL text when the engine is given", async () => { + const plan = await parseExplainFor(MYSQL_TEXT, "mysql"); expect(plan.driver).toBe("mysql"); expect(plan.root.node_type).toBe("Nested Loop"); expect(plan.has_analyze_data).toBe(true); }); - it("still fails on MySQL JSON without a hint, as before", () => { + it("still fails on MySQL JSON without a hint, as before", async () => { // Sniffing sees a leading `{` and tries Postgres, which has no `Plan` key. - expect(() => parseExplain(MYSQL_JSON)).toThrow(/Plan/); + await expect(parseExplain(MYSQL_JSON)).rejects.toThrow(/Plan/); }); }); describe("the unhinted path is unchanged", () => { - it("matches the previous sniffing behaviour", () => { - expect(detectFormat(POSTGRES_JSON)).toBe("postgres-json"); - expect(detectFormat(POSTGRES_TEXT)).toBe("postgres-text"); - expect(detectFormatFor(POSTGRES_JSON, null)).toBe(detectFormat(POSTGRES_JSON)); - expect(() => detectFormat("not a plan at all")).toThrow(/Unsupported/); + it("matches the previous sniffing behaviour", async () => { + expect(await detectFormat(POSTGRES_JSON)).toBe("postgres-json"); + expect(await detectFormat(POSTGRES_TEXT)).toBe("postgres-text"); + expect(await detectFormatFor(POSTGRES_JSON, null)).toBe(await detectFormat(POSTGRES_JSON)); + await expect(detectFormat("not a plan at all")).rejects.toThrow(/Unsupported/); }); - it("agrees with sniffing when a postgres hint is given", () => { + it("agrees with sniffing when a postgres hint is given", async () => { for (const raw of [POSTGRES_JSON, POSTGRES_TEXT]) { - expect(detectFormatFor(raw, "postgres")).toBe(detectFormat(raw)); + expect(await detectFormatFor(raw, "postgres")).toBe(await detectFormat(raw)); } - const plan = parseExplainFor(POSTGRES_JSON, "postgres"); + const plan = await parseExplainFor(POSTGRES_JSON, "postgres"); expect(plan.driver).toBe("postgres"); }); }); describe("edges", () => { - it("reports that sqlite has no text form", () => { - expect(() => parseExplainFor("SCAN users", "sqlite")).toThrow( + it("reports that sqlite has no text form", async () => { + await expect(parseExplainFor("SCAN users", "sqlite")).rejects.toThrow( /buildSqliteTree/, ); }); - it("rejects empty input for every engine", () => { - expect(() => parseExplainFor("", null)).toThrow(); - expect(() => parseExplainFor("", "postgres")).toThrow(); - expect(() => parseExplainFor(" \n ", "mysql")).toThrow(); + it("rejects empty input for every engine", async () => { + await expect(parseExplainFor("", null)).rejects.toThrow(); + await expect(parseExplainFor("", "postgres")).rejects.toThrow(); + await expect(parseExplainFor(" \n ", "mysql")).rejects.toThrow(); }); - it("reports MySQL JSON missing its query_block", () => { - expect(() => parseExplainFor('{"not_a_block": 1}', "mysql")).toThrow( + it("reports MySQL JSON missing its query_block", async () => { + await expect(parseExplainFor('{"not_a_block": 1}', "mysql")).rejects.toThrow( /query_block/, ); }); @@ -127,15 +127,15 @@ describe("source dispatch", () => { }); describe("withSourceLabel", () => { - it("labels a plan with an empty original query", () => { - const plan = parseExplainFor(POSTGRES_JSON, "postgres"); + it("labels a plan with an empty original query", async () => { + const plan = await parseExplainFor(POSTGRES_JSON, "postgres"); const labelled = withSourceLabel(plan, "plan.json"); expect(labelled.original_query).toBe("-- loaded from plan.json"); }); - it("leaves a stamped original query alone", () => { + it("leaves a stamped original query alone", async () => { const plan = { - ...parseExplainFor(POSTGRES_JSON, "postgres"), + ...(await parseExplainFor(POSTGRES_JSON, "postgres")), original_query: "SELECT 1", }; expect(withSourceLabel(plan, "plan.json").original_query).toBe("SELECT 1"); diff --git a/packages/explain/tests/raw.test.ts b/packages/explain/tests/raw.test.ts index 3c080fa9a..db8225bd9 100644 --- a/packages/explain/tests/raw.test.ts +++ b/packages/explain/tests/raw.test.ts @@ -15,8 +15,8 @@ function raw(overrides: Partial): RawExplainOutput { describe("raw", () => { describe("parseRawExplain", () => { - it("parses a postgres-json payload and stamps engine and query", () => { - const plan = parseRawExplain( + it("parses a postgres-json payload and stamps engine and query", async () => { + const plan = await parseRawExplain( raw({ payload: '[{ "Plan": { "Node Type": "Seq Scan", "Relation Name": "users" } }]', original_query: "SELECT * FROM users", @@ -28,8 +28,8 @@ describe("raw", () => { expect(plan.root.node_type).toBe("Seq Scan"); }); - it("parses a mysql-json payload", () => { - const plan = parseRawExplain( + it("parses a mysql-json payload", async () => { + const plan = await parseRawExplain( raw({ engine: "mysql", format: "mysql-json", @@ -43,8 +43,8 @@ describe("raw", () => { expect(plan.root.relation).toBe("users"); }); - it("parses a mysql-analyze-text payload", () => { - const plan = parseRawExplain( + it("parses a mysql-analyze-text payload", async () => { + const plan = await parseRawExplain( raw({ engine: "mysql", format: "mysql-analyze-text", @@ -57,8 +57,8 @@ describe("raw", () => { expect(plan.has_analyze_data).toBe(true); }); - it("parses mysql-tabular-rows from a JSON array payload", () => { - const plan = parseRawExplain( + it("parses mysql-tabular-rows from a JSON array payload", async () => { + const plan = await parseRawExplain( raw({ engine: "mysql", format: "mysql-tabular-rows", @@ -82,8 +82,8 @@ describe("raw", () => { expect(plan.root.children[0].plan_rows).toBe(42); }); - it("parses sqlite-eqp-rows from a JSON array payload", () => { - const plan = parseRawExplain( + it("parses sqlite-eqp-rows from a JSON array payload", async () => { + const plan = await parseRawExplain( raw({ engine: "sqlite", format: "sqlite-eqp-rows", @@ -96,17 +96,17 @@ describe("raw", () => { expect(plan.root.relation).toBe("users"); }); - it("rejects a rows payload that is not a JSON array", () => { - expect(() => + it("rejects a rows payload that is not a JSON array", async () => { + await expect( parseRawExplain( raw({ engine: "sqlite", format: "sqlite-eqp-rows", payload: "{}" }), ), - ).toThrow(/JSON array/); + ).rejects.toThrow(/JSON array/); }); }); describe("resolveExplainOutput", () => { - it("passes a plugin's already parsed plan through untouched", () => { + it("passes a plugin's already parsed plan through untouched", async () => { const plan: ExplainPlan = { root: { id: "node_0", @@ -135,11 +135,11 @@ describe("raw", () => { raw_output: null, }; - expect(resolveExplainOutput({ kind: "plan", plan })).toBe(plan); + await expect(resolveExplainOutput({ kind: "plan", plan })).resolves.toBe(plan); }); - it("parses a raw payload from a built-in driver", () => { - const resolved = resolveExplainOutput({ + it("parses a raw payload from a built-in driver", async () => { + const resolved = await resolveExplainOutput({ kind: "raw", raw: raw({ payload: '{ "Plan": { "Node Type": "Result" } }', @@ -149,4 +149,4 @@ describe("raw", () => { expect(resolved.original_query).toBe("SELECT 1"); }); }); -}); +}); \ No newline at end of file diff --git a/packages/explain/tests/registry.test.ts b/packages/explain/tests/registry.test.ts index 4def24460..c543dd614 100644 --- a/packages/explain/tests/registry.test.ts +++ b/packages/explain/tests/registry.test.ts @@ -70,7 +70,7 @@ afterEach(() => { }); describe("EXPLAIN parser registry", () => { - it("keeps built-in behavior unchanged with no mutable parsers", () => { + it("keeps built-in behavior unchanged with no mutable parsers", async () => { expect(listExplainParsers().map(({ format }) => format)).toEqual([ "postgres-json", "postgres-text", @@ -82,19 +82,19 @@ describe("EXPLAIN parser registry", () => { ]); expect(Object.isFrozen(listExplainParsers())).toBe(true); - const postgres = parseRawExplain({ + const postgres = await parseRawExplain({ engine: "postgres", format: "postgres-json", payload: '[{ "Plan": { "Node Type": "Result" } }]', original_query: "SELECT 1", }); expect(postgres.root.node_type).toBe("Result"); - expect(detectFormat("Seq Scan on users (cost=0.00..1.00 rows=1 width=4)")).toBe( + expect(await detectFormat("Seq Scan on users (cost=0.00..1.00 rows=1 width=4)")).toBe( "postgres-text", ); }); - it("registers and dispatches a custom parser for raw and source payloads", () => { + it("registers and dispatches a custom parser for raw and source payloads", async () => { registerExplainParser({ engine: "acme", format: "acme-plan", @@ -104,20 +104,20 @@ describe("EXPLAIN parser registry", () => { }); expect(getExplainParser("acme-plan")?.label).toBe("Acme plan"); - expect(detectFormatFor("ACME PLAN", "ACME")).toBe("acme-plan"); - expect(detectFormat("ACME PLAN")).toBe("acme-plan"); - expect(parseExplainFor("ACME PLAN", "acme").root.node_type).toBe( + expect(await detectFormatFor("ACME PLAN", "ACME")).toBe("acme-plan"); + expect(await detectFormat("ACME PLAN")).toBe("acme-plan"); + expect((await parseExplainFor("ACME PLAN", "acme")).root.node_type).toBe( "Acme Scan", ); expect(explainEngineFromDriverName(" AcMe ")).toBe("acme"); - const parsed = parseRawExplain(raw("acme-plan")); + const parsed = await parseRawExplain(raw("acme-plan")); expect(parsed.root.node_type).toBe("Acme Scan"); expect(parsed.driver).toBe("acme"); expect(parsed.original_query).toBe("SELECT 1"); }); - it("replaces a registration in place and warns exactly once", () => { + it("replaces a registration in place and warns exactly once", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const first = { engine: "acme", @@ -143,7 +143,7 @@ describe("EXPLAIN parser registry", () => { expect( listExplainParsers().findIndex(({ format }) => format === "acme-plan"), ).toBe(position); - expect(parseRawExplain(raw("acme-plan")).root.node_type).toBe("Replacement"); + expect((await parseRawExplain(raw("acme-plan"))).root.node_type).toBe("Replacement"); }); it("removes custom parsers and reveals overridden built-ins", () => { @@ -170,14 +170,14 @@ describe("EXPLAIN parser registry", () => { expect(getExplainParser("acme-plan")).toBeNull(); }); - it("throws the exact owner-directed error for an unknown raw format", () => { - expect(() => parseRawExplain(raw("unknown-plan"))).toThrow( + it("throws the exact owner-directed error for an unknown raw format", async () => { + await expect(parseRawExplain(raw("unknown-plan"))).rejects.toThrow( "No EXPLAIN parser registered for format 'unknown-plan' (engine 'acme'). " + "Import the parser package for 'acme' before parsing.", ); }); - it("keeps built-in sniffing ahead of custom sniffers", () => { + it("keeps built-in sniffing ahead of custom sniffers", async () => { const sniff = vi.fn(() => true); registerExplainParser({ engine: "acme", @@ -186,13 +186,13 @@ describe("EXPLAIN parser registry", () => { sniff, }); - expect(detectFormat('[{ "Plan": { "Node Type": "Result" } }]')).toBe( + expect(await detectFormat('[{ "Plan": { "Node Type": "Result" } }]')).toBe( "postgres-json", ); expect(sniff).not.toHaveBeenCalled(); }); - it("tries custom sniffers in registration order and ignores failures", () => { + it("tries custom sniffers in registration order and ignores failures", async () => { registerExplainParser({ engine: "acme", format: "throwing-plan", @@ -214,8 +214,8 @@ describe("EXPLAIN parser registry", () => { sniff: () => true, }); - expect(detectFormat("CUSTOM PLAN")).toBe("first-plan"); - expect(detectFormatFor("CUSTOM PLAN", "acme")).toBe("first-plan"); + expect(await detectFormat("CUSTOM PLAN")).toBe("first-plan"); + expect(await detectFormatFor("CUSTOM PLAN", "acme")).toBe("first-plan"); }); it("validates registrations before mutating the registry", () => { @@ -233,7 +233,7 @@ describe("EXPLAIN parser registry", () => { expect(getExplainParser("acme-plan")).toBeNull(); }); - it("propagates parser exceptions unchanged", () => { + it("propagates parser exceptions unchanged", async () => { const error = new Error("custom parser failed"); registerExplainParser({ engine: "acme", @@ -243,6 +243,6 @@ describe("EXPLAIN parser registry", () => { }, }); - expect(() => parseRawExplain(raw("acme-plan"))).toThrow(error); + await expect(parseRawExplain(raw("acme-plan"))).rejects.toThrow(error); }); }); diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index d1fdcea3b..abb801189 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -35,15 +35,22 @@ export default MySlot.component; | Hook | Returns | Purpose | |------|---------|---------| -| `usePluginQuery()` | `{ executeQuery, loading, error }` | Execute read-only queries on the active connection | +| `usePluginQuery()` | `{ executeQuery, loading, error }` | Execute read-only SQL on the active connection; writes and unrecognized statements are rejected | | `usePluginConnection()` | `{ connectionId, driver, schema }` | Active connection metadata | | `usePluginToast()` | `{ showInfo, showError, showWarning }` | System notifications | -| `usePluginModal()` | `{ openModal, closeModal }` | Host-managed modal with custom content | +| `usePluginModal()` | `{ openModal, closeModal }` | Modal with custom React content rendered inside the plugin iframe | | `usePluginSetting(pluginId)` | `{ getSetting, setSetting, setSettings }` | Read/write the plugin's own settings | | `usePluginTheme()` | `{ themeId, themeName, isDark, colors }` | Current theme tokens | | `usePluginTranslation(pluginId)` | `t(key)` | Plugin-scoped i18next translator | | `openUrl(url)` | `Promise` | Open a URL in the system browser | +Plugin UI runs in a sandboxed iframe and has no direct Tauri `invoke` access. +The host exposes only the operations represented by these APIs: read-only +queries on the active connection, plugin-owned settings, notifications, +translations, theme and slot context, and opening HTTP(S) URLs. Modal content +stays inside the plugin iframe. Setting writes are asynchronous; await +`setSetting` or `setSettings` when subsequent work depends on persistence. + All hooks must run inside a React component loaded by Tabularis. Calling them outside the host throws a clear error instead of failing silently. ## Compatibility @@ -55,9 +62,9 @@ import { assertHostCompat } from "@tabularis/plugin-api"; assertHostCompat(); // throws if the running Tabularis is older than MIN_HOST_VERSION ``` -| Package version | Minimum Tabularis | Notes | -|-----------------|-------------------|-------| -| `0.1.0` | `0.1.0` (see host `HOST_API_VERSION`) | Initial release | +| Package version | Minimum Tabularis API | Notes | +|-----------------|-----------------------|-------| +| `0.2.0` | `0.2.0` | Sandboxed UI API with explicit RPC capabilities | ## Slot reference diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 7864cf43e..2cc6c365a 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -91,8 +91,8 @@ export interface UsePluginToastReturn { export interface UsePluginSettingReturn { getSetting: (key: string, defaultValue?: T) => T; - setSetting: (key: string, value: unknown) => void; - setSettings: (updates: Record) => void; + setSetting: (key: string, value: unknown) => Promise; + setSettings: (updates: Record) => Promise; } export interface UsePluginModalReturn { diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index e79983b3b..c6017e151 100644 --- a/packages/plugin-api/src/version.ts +++ b/packages/plugin-api/src/version.ts @@ -2,11 +2,11 @@ * API version of this package. Must match the version field of package.json. * Bump when the host API shape changes in a way that plugin bundles can observe. */ -export const API_VERSION = "0.1.1"; +export const API_VERSION = "0.2.0"; /** * Minimum Tabularis host version that exposes an API compatible with this package. * The host sets `window.__TABULARIS_API_VERSION__`; `assertHostCompat()` uses this * constant to decide whether the active host is new enough. */ -export const MIN_HOST_VERSION = "0.1.0"; +export const MIN_HOST_VERSION = "0.2.0"; diff --git a/packages/plugin-guest/build.mjs b/packages/plugin-guest/build.mjs new file mode 100644 index 000000000..f072b1987 --- /dev/null +++ b/packages/plugin-guest/build.mjs @@ -0,0 +1,29 @@ +import { createRequire } from "node:module"; +import { mkdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const requireFromRoot = createRequire(path.join(root, "package.json")); +let esbuild; +try { + esbuild = requireFromRoot("esbuild"); +} catch { + // pnpm may have esbuild available through Vite without a root-level link. + const requireFromVite = createRequire(requireFromRoot.resolve("vite")); + esbuild = requireFromVite("esbuild"); +} +const output = path.join(root, "src-tauri/resources/guest/runtime.js"); +mkdirSync(path.dirname(output), { recursive: true }); + +await esbuild.build({ + entryPoints: [path.join(root, "packages/plugin-guest/src/runtime.ts")], + outfile: output, + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + minify: true, + legalComments: "none", + define: { "process.env.NODE_ENV": '"production"' }, +}); diff --git a/packages/plugin-guest/src/runtime.ts b/packages/plugin-guest/src/runtime.ts new file mode 100644 index 000000000..0c948ad62 --- /dev/null +++ b/packages/plugin-guest/src/runtime.ts @@ -0,0 +1,290 @@ +import * as React from "react"; +import * as ReactJSXRuntime from "react/jsx-runtime"; +import { createRoot } from "react-dom/client"; +import * as explainApi from "@tabularis/explain"; +import { RpcPort } from "../../../src/plugins/sandbox/rpc"; + +type PluginComponent = React.ComponentType<{ context: Record; pluginId: string }>; +type GuestGlobal = Window & Record; + +const guestWindow = window as unknown as GuestGlobal; +const params = new URLSearchParams(location.search); +const modulePath = params.get("module") ?? ""; +const mode = params.get("mode") ?? "ui"; +const pluginId = decodeURIComponent(location.pathname.split("/")[1] ?? ""); +let currentContext: Record = {}; +let renderPlugin: (() => void) | undefined; +let explainModulePromise: Promise[]> | undefined; +let rpcPort: RpcPort | undefined; + +interface GuestModalOptions { + title: string; + content: React.ReactNode; + size?: "sm" | "md" | "lg" | "xl"; +} + +interface GuestModalApi { + openModal: (options: GuestModalOptions) => void; + closeModal: () => void; +} + +const GuestModalContext = React.createContext(null); + +function defineSlot(slot: string, component: unknown) { + return { __slot: slot, component } as const; +} + +/** The UI extension API deliberately contains no Tauri or generic invoke access. */ +const guestApi = Object.freeze({ + defineSlot, + usePluginConnection: () => ({ + connectionId: currentContext.connectionId ?? null, + driver: currentContext.driver ?? null, + schema: currentContext.schema ?? null, + }), + usePluginQuery: () => { + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(null); + const executeQuery = React.useCallback(async (query: string) => { + if (!rpcPort) throw new Error("Plugin host connection is unavailable"); + setLoading(true); + setError(null); + try { + return (await rpcPort.call("api.query", [query])) as { + columns: string[]; + rows: unknown[][]; + }; + } catch (failure) { + const message = failure instanceof Error ? failure.message : String(failure); + setError(message); + throw new Error(message); + } finally { + setLoading(false); + } + }, []); + return { executeQuery, loading, error }; + }, + usePluginToast: () => ({ + showInfo: (text: string) => callHost("api.toast", "info", text), + showError: (text: string) => callHost("api.toast", "error", text), + showWarning: (text: string) => callHost("api.toast", "warning", text), + }), + usePluginSetting: (requestedPluginId: string) => { + if (requestedPluginId !== pluginId) throw new Error("Plugins may only access their own settings"); + const settings = asRecord(currentContext.pluginSettings); + const getSetting = (key: string, defaultValue?: T): T => + (Object.hasOwn(settings, key) ? settings[key] : defaultValue) as T; + const setSetting = async (key: string, value: unknown): Promise => { + await callHost("api.setting.set", key, value); + currentContext = { + ...currentContext, + pluginSettings: { ...asRecord(currentContext.pluginSettings), [key]: value }, + }; + renderPlugin?.(); + }; + const setSettings = async (updates: Record): Promise => { + for (const [key, value] of Object.entries(updates)) await setSetting(key, value); + }; + return { getSetting, setSetting, setSettings }; + }, + usePluginTranslation: (requestedPluginId: string) => { + if (requestedPluginId !== pluginId) throw new Error("Plugins may only access their own translations"); + const translations = asRecord(currentContext.translations); + return (key: string, options?: Record): string => { + let value: unknown = translations; + for (const part of key.split(".")) value = asRecord(value)[part]; + let translated = typeof value === "string" ? value : key; + for (const [name, replacement] of Object.entries(options ?? {})) { + translated = translated.replaceAll(`{{${name}}}`, String(replacement)); + } + return translated; + }; + }, + usePluginModal: () => { + const api = React.useContext(GuestModalContext); + if (!api) throw new Error("Plugin modal API must be used inside a plugin component"); + return api; + }, + usePluginTheme: () => { + const theme = asRecord(currentContext.theme); + return { + themeId: typeof theme.themeId === "string" ? theme.themeId : null, + themeName: typeof theme.themeName === "string" ? theme.themeName : null, + isDark: theme.isDark === true, + colors: Object.keys(asRecord(theme.colors)).length > 0 ? asRecord(theme.colors) : null, + }; + }, + openUrl: (url: string) => callHost("api.openUrl", url), +}); + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : {}; +} + +function callHost(method: string, ...args: unknown[]): Promise { + if (!rpcPort) return Promise.reject(new Error("Plugin host connection is unavailable")); + return rpcPort.call(method, args).then(() => undefined); +} + +function GuestRoot({ component: Component }: { component: PluginComponent }) { + const [modal, setModal] = React.useState(null); + const modalApi = React.useMemo(() => ({ + openModal: (options: GuestModalOptions) => setModal(options), + closeModal: () => setModal(null), + }), []); + const modalStyle: React.CSSProperties = { + position: "fixed", inset: 0, zIndex: 2147483647, display: "grid", + placeItems: "center", padding: 16, background: "rgba(0,0,0,.6)", + }; + const dialogStyle: React.CSSProperties = { + width: modal?.size === "sm" + ? "min(100%, 500px)" + : modal?.size === "md" + ? "min(100%, 700px)" + : modal?.size === "xl" + ? "min(100%, 1100px)" + : "min(100%, 900px)", + maxHeight: "90%", overflow: "auto", + background: "#111827", color: "#f9fafb", border: "1px solid #374151", + borderRadius: 12, padding: 16, + }; + return React.createElement( + GuestModalContext.Provider, + { value: modalApi }, + React.createElement(Component, { context: currentContext, pluginId }), + modal && React.createElement("div", { style: modalStyle, onMouseDown: (event: React.MouseEvent) => { + if (event.target === event.currentTarget) setModal(null); + } }, React.createElement("section", { role: "dialog", "aria-modal": true, "aria-label": modal.title, style: dialogStyle, + onKeyDown: (event: React.KeyboardEvent) => { if (event.key === "Escape") setModal(null); }, tabIndex: -1 }, + React.createElement("header", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 12 } }, + React.createElement("h2", null, modal.title), + React.createElement("button", { type: "button", onClick: () => setModal(null), "aria-label": "Close dialog" }, "×")), + modal.content)), + ); +} + +Object.assign(guestWindow, { + React, + ReactJSXRuntime, + __TABULARIS_EXPLAIN__: explainApi, + __TABULARIS_API__: guestApi, + __TABULARIS_API_VERSION__: "0.2.0", +}); + +function loadExplainModule(): Promise[]> { + if (explainModulePromise) return explainModulePromise; + explainModulePromise = new Promise((resolve, reject) => { + const pluginScript = document.createElement("script"); + pluginScript.src = `/${encodeURIComponent(pluginId)}/${modulePath.replace(/^\.\//, "").replace(/^\//, "")}`; + pluginScript.onload = () => { + const raw = guestWindow.__tabularis_explain_parser__; + const exported = + raw && typeof raw === "object" && Object.prototype.hasOwnProperty.call(raw, "default") + ? (raw as { default: unknown }).default + : raw; + const candidates = exported == null ? [] : Array.isArray(exported) ? exported : [exported]; + const parsers = candidates.filter( + (value): value is Record => + !!value && + typeof value === "object" && + typeof (value as Record).engine === "string" && + typeof (value as Record).format === "string" && + typeof (value as Record).parse === "function", + ); + resolve(parsers); + }; + pluginScript.onerror = () => reject(new Error("Plugin parser bundle failed to load")); + document.head.append(pluginScript); + }); + return explainModulePromise; +} + +function start(port: MessagePort): void { + const rpc = new RpcPort(port, async (method, args) => { + if (mode === "explain") { + const values = Array.isArray(args) ? args : [args]; + if (method === "explain.load") { + if (values[0] !== modulePath) throw new Error("module_mismatch"); + const parsers = await loadExplainModule(); + return parsers.map((parser) => ({ + engine: parser.engine, + format: parser.format, + ...(typeof parser.label === "string" ? { label: parser.label } : {}), + hasSniff: typeof parser.sniff === "function", + })); + } + if (method === "explain.parse" || method === "explain.sniff") { + const [format, payload] = values; + if (typeof format !== "string" || typeof payload !== "string") { + throw new Error("invalid_parser_arguments"); + } + const parser = (await loadExplainModule()).find((candidate) => candidate.format === format); + if (!parser) throw new Error("parser_not_found"); + if (method === "explain.parse") { + return (parser.parse as (raw: string) => unknown)(payload); + } + if (typeof parser.sniff !== "function") return false; + return (parser.sniff as (raw: string) => unknown)(payload); + } + throw new Error("method_not_allowed"); + } + if (method === "slot.context") { + const next = Array.isArray(args) ? args[0] : args; + if (next && typeof next === "object" && !Array.isArray(next)) { + currentContext = next as Record; + renderPlugin?.(); + } + return null; + } + throw new Error("method_not_allowed"); + }); + rpcPort = rpc; + + if (mode === "explain") return; + + const pluginScript = document.createElement("script"); + pluginScript.src = `/${encodeURIComponent(pluginId)}/${modulePath.replace(/^\.\//, "").replace(/^\//, "")}`; + pluginScript.onload = () => { + const raw = guestWindow.__tabularis_plugin__; + const component = + typeof raw === "function" + ? raw + : raw && typeof raw === "object" + ? (raw as { default?: unknown }).default + : null; + if (typeof component !== "function") { + void rpc.call("host.loadFailed", ["Plugin bundle has no default component export"]).catch(() => undefined); + return; + } + + const rootElement = document.getElementById("root"); + if (!rootElement) return; + const root = createRoot(rootElement); + renderPlugin = () => root.render(React.createElement(GuestRoot, { component: component as PluginComponent })); + renderPlugin(); + const resizeObserver = new ResizeObserver(() => { + void rpc.call("ui.resize", [document.documentElement.scrollHeight]).catch(() => undefined); + }); + resizeObserver.observe(document.body); + }; + pluginScript.onerror = () => { + void rpc.call("host.loadFailed", ["Plugin bundle failed to load"]).catch(() => undefined); + }; + document.head.append(pluginScript); +} + +const announceReady = () => window.parent.postMessage({ type: "tabularis:ready" }, "*"); +const readyTimer = window.setInterval(announceReady, 100); +announceReady(); + +window.addEventListener("message", function onInit(event: MessageEvent) { + if (event.source !== window.parent || event.data?.type !== "tabularis:init") return; + const port = event.ports[0]; + if (!port) return; + window.clearInterval(readyTimer); + window.removeEventListener("message", onInit); + start(port); +}); + diff --git a/plugins/EXPLAIN_SANDBOX_DESIGN.md b/plugins/EXPLAIN_SANDBOX_DESIGN.md new file mode 100644 index 000000000..ade5809cd --- /dev/null +++ b/plugins/EXPLAIN_SANDBOX_DESIGN.md @@ -0,0 +1,36 @@ +# EXPLAIN parser sandbox design + +## Boundary + +EXPLAIN parser bundles run in the same sandboxed `tabularis-plugin` iframe used +for UI extensions. Each parser module gets an isolated frame with +`sandbox="allow-scripts"`, a restrictive guest CSP (`connect-src 'none'`), and +no Tauri window or command bridge. The host transfers only raw plan text to a +parser and receives a structured-cloneable plan result. Parser metadata +(`engine`, `format`, optional `label`, and whether `sniff` is available) is the +only information returned when a bundle loads. + +The frame receives no filesystem API, connection data, secrets, or generic +command proxy. Parser failures are returned as errors and remain isolated to +the current parse request. + +## API and migration + +Iframe messaging is asynchronous. `RegisteredExplainParser.parse` and `sniff` +may therefore return promises, and package helpers that dispatch to a parser +(`parseRawExplain`, `resolveExplainOutput`, `parseExplain`, `parseExplainFor`, +`detectFormat`, and `detectFormatFor`) are asynchronous. Callers must await +them. + +Bundles continue to export `__tabularis_explain_parser__` as one parser or an +array. The bundle's external `@tabularis/explain` global exists only inside the +guest. The host no longer evaluates a bundle or receives parser functions. +Existing EXPLAIN plugins keep their descriptor shape; callers that depended on +synchronous parser helpers need the async migration above. + +## Lifecycle + +The plugin loader creates one hidden frame per declared module and shares that +frame among the module's declared formats. When the enabled-plugin set changes, +the host unregisters format wrappers, closes the message ports, and removes the +frames. A module load or parser error does not stop other plugins from loading. diff --git a/plugins/PLUGIN_GUIDE.md b/plugins/PLUGIN_GUIDE.md index 7de56ac2e..002bbaca0 100644 --- a/plugins/PLUGIN_GUIDE.md +++ b/plugins/PLUGIN_GUIDE.md @@ -484,12 +484,15 @@ export default function FieldPreview({ context }: SlotComponentProps) { Slot components can import these hooks from `@tabularis/plugin-api`: +The iframe boundary, RPC capability list, and migration contract are described +in [`UI_SANDBOX_DESIGN.md`](./UI_SANDBOX_DESIGN.md). + | Hook | Returns | Purpose | |------|---------|---------| -| `usePluginQuery()` | `(query: string) => Promise<{ columns, rows }>` | Execute read-only queries on the active connection | +| `usePluginQuery()` | `(query: string) => Promise<{ columns, rows }>` | Execute read-only SQL on the active connection; writes and unrecognized statements are rejected | | `usePluginConnection()` | `{ connectionId, driver, schema }` | Access active connection metadata | | `usePluginToast()` | `{ showInfo(), showError(), showWarning() }` | Show toast notifications | -| `usePluginModal()` | `{ openModal(options), closeModal() }` | Open host-managed modals with custom content | +| `usePluginModal()` | `{ openModal(options), closeModal() }` | Show custom React content in a modal contained in the plugin iframe | | `usePluginSetting(pluginId)` | `{ getSetting(key), setSetting(key, value) }` | Read/write plugin settings | | `usePluginTheme()` | `{ themeId, themeName, isDark, colors }` | Access current theme info | | `usePluginTranslation(pluginId)` | `t(key)` | Access plugin-specific i18n translations | @@ -497,7 +500,12 @@ Slot components can import these hooks from `@tabularis/plugin-api`: #### Plugin Modal -`usePluginModal()` lets you open a host-managed modal from within a slot component: +`usePluginModal()` lets you open a modal from within a slot component. Its React +content stays inside the sandboxed plugin iframe; it cannot render into the +host application's React tree. Keep the plugin frame open while the modal is +in use, and provide a close button in longer workflows. `setSetting` and +`setSettings` now return promises; await them when the next operation depends +on the saved value. ```tsx const { openModal, closeModal } = usePluginModal(); @@ -583,9 +591,13 @@ must not be absolute or contain `..`. Build the module as an IIFE named `__tabularis_explain_parser__`, externalize `@tabularis/explain` to `__TABULARIS_EXPLAIN__`, and default-export either one -`RegisteredExplainParser` or an array. The host reads each declared module -once, matches exports by exact `engine` and `format`, applies the manifest -label, and calls `registerExplainParser`. For example: +`RegisteredExplainParser` or an array. The sandbox reads each declared module +once, sends only parser metadata to the host, and the host matches exports by +exact `engine` and `format` before registering an asynchronous proxy. Parser +functions stay inside the sandbox. EXPLAIN helpers that dispatch to parsers are +asynchronous and must be awaited. For migration details, see +[`EXPLAIN_SANDBOX_DESIGN.md`](./EXPLAIN_SANDBOX_DESIGN.md). +For example: ```ts import type { RegisteredExplainParser } from "@tabularis/explain"; diff --git a/plugins/UI_SANDBOX_DESIGN.md b/plugins/UI_SANDBOX_DESIGN.md new file mode 100644 index 000000000..4c1247ba2 --- /dev/null +++ b/plugins/UI_SANDBOX_DESIGN.md @@ -0,0 +1,39 @@ +# UI plugin sandbox design + +## Boundary + +Each UI extension module runs in a sandboxed `tabularis-plugin` iframe with +scripts enabled, same-origin access disabled, and a guest CSP that blocks +network connections. The host and guest communicate over a transferred +`MessagePort`. Host requests are checked against an explicit method allow-list; +there is no generic Tauri command or `invoke` method in the guest API. + +## Exposed capabilities + +- Slot context, active connection metadata, theme data, and the plugin's own + settings and translations. +- Read-only SQL on the currently active connection. The host rejects writes + and statements the SQL classifier cannot identify as read-only. +- Writes to the calling plugin's own JSON settings, capped at 64 KiB per value. +- Native info, warning, and error dialogs; opening HTTP(S) URLs without URL + credentials. +- Plugin-authored React modals rendered inside that plugin's iframe. + +The frame receives no connection credentials, filesystem interface, or generic +backend command proxy. Database results returned by the read-only query API are +available to the plugin that requested them. + +## Plugin migration + +Plugins continue to import hooks from `@tabularis/plugin-api`; rebuild against +API `0.2.0` and require host API `0.2.0`. `setSetting` and `setSettings` now +return promises, so await them when subsequent work depends on persistence. +Modals remain React-based, but render within the plugin iframe rather than the +host React tree. Translation lookup supports dotted keys and `{{name}}` +interpolation; plugins that rely on advanced i18next features need to provide +their own formatting or simplify those entries. `openUrl` accepts only HTTP(S) +URLs and rejects embedded credentials. `usePluginQuery` is read-only. + +Plugins must not import Tauri APIs or use `window.__TAURI__` directly. The host +injects `__TABULARIS_API__` only in the guest frame; `__TABULARIS_EXPLAIN__` is +also available there for EXPLAIN parser bundles. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ab8c4e52..c507a42a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.26 version: 0.4.26(eslint@9.39.4(jiti@2.6.1)) + esbuild: + specifier: ^0.27.4 + version: 0.27.4 gitnexus: specifier: ^1.6.9 version: 1.6.9(graphology-types@0.24.8)(zod@4.3.6) diff --git a/src-tauri/resources/guest/__guest__.html b/src-tauri/resources/guest/__guest__.html new file mode 100644 index 000000000..88973e658 --- /dev/null +++ b/src-tauri/resources/guest/__guest__.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/src-tauri/resources/guest/host.css b/src-tauri/resources/guest/host.css new file mode 100644 index 000000000..52cd7f143 --- /dev/null +++ b/src-tauri/resources/guest/host.css @@ -0,0 +1,2 @@ +/* Placeholder host CSS (design note section 8.8). */ +body { margin: 0; font-family: system-ui, sans-serif; } diff --git a/src-tauri/resources/guest/runtime.js b/src-tauri/resources/guest/runtime.js new file mode 100644 index 000000000..032a0ecd5 --- /dev/null +++ b/src-tauri/resources/guest/runtime.js @@ -0,0 +1,14 @@ +(()=>{var Ey=Object.create;var wi=Object.defineProperty;var by=Object.getOwnPropertyDescriptor;var Sy=Object.getOwnPropertyNames;var xy=Object.getPrototypeOf,_y=Object.prototype.hasOwnProperty;var nl=(t,l)=>()=>(l||t((l={exports:{}}).exports,l),l.exports),Ty=(t,l)=>{for(var e in l)wi(t,e,{get:l[e],enumerable:!0})},My=(t,l,e,n)=>{if(l&&typeof l=="object"||typeof l=="function")for(let a of Sy(l))!_y.call(t,a)&&a!==e&&wi(t,a,{get:()=>l[a],enumerable:!(n=by(l,a))||n.enumerable});return t};var ji=(t,l,e)=>(e=t!=null?Ey(xy(t)):{},My(l||!t||!t.__esModule?wi(e,"default",{value:t,enumerable:!0}):e,t));var $o=nl(A=>{"use strict";var Gi=Symbol.for("react.transitional.element"),Ay=Symbol.for("react.portal"),zy=Symbol.for("react.fragment"),Ny=Symbol.for("react.strict_mode"),Oy=Symbol.for("react.profiler"),Ry=Symbol.for("react.consumer"),Dy=Symbol.for("react.context"),qy=Symbol.for("react.forward_ref"),Uy=Symbol.for("react.suspense"),Cy=Symbol.for("react.memo"),Ko=Symbol.for("react.lazy"),Hy=Symbol.for("react.activity"),Go=Symbol.iterator;function By(t){return t===null||typeof t!="object"?null:(t=Go&&t[Go]||t["@@iterator"],typeof t=="function"?t:null)}var Vo={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Jo=Object.assign,Wo={};function Ce(t,l,e){this.props=t,this.context=l,this.refs=Wo,this.updater=e||Vo}Ce.prototype.isReactComponent={};Ce.prototype.setState=function(t,l){if(typeof t!="object"&&typeof t!="function"&&t!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,t,l,"setState")};Ce.prototype.forceUpdate=function(t){this.updater.enqueueForceUpdate(this,t,"forceUpdate")};function ko(){}ko.prototype=Ce.prototype;function Xi(t,l,e){this.props=t,this.context=l,this.refs=Wo,this.updater=e||Vo}var Qi=Xi.prototype=new ko;Qi.constructor=Xi;Jo(Qi,Ce.prototype);Qi.isPureReactComponent=!0;var Xo=Array.isArray;function Yi(){}var Q={H:null,A:null,T:null,S:null},Fo=Object.prototype.hasOwnProperty;function Zi(t,l,e){var n=e.ref;return{$$typeof:Gi,type:t,key:l,ref:n!==void 0?n:null,props:e}}function wy(t,l){return Zi(t.type,l,t.props)}function Ki(t){return typeof t=="object"&&t!==null&&t.$$typeof===Gi}function jy(t){var l={"=":"=0",":":"=2"};return"$"+t.replace(/[=:]/g,function(e){return l[e]})}var Qo=/\/+/g;function Li(t,l){return typeof t=="object"&&t!==null&&t.key!=null?jy(""+t.key):l.toString(36)}function Ly(t){switch(t.status){case"fulfilled":return t.value;case"rejected":throw t.reason;default:switch(typeof t.status=="string"?t.then(Yi,Yi):(t.status="pending",t.then(function(l){t.status==="pending"&&(t.status="fulfilled",t.value=l)},function(l){t.status==="pending"&&(t.status="rejected",t.reason=l)})),t.status){case"fulfilled":return t.value;case"rejected":throw t.reason}}throw t}function Ue(t,l,e,n,a){var u=typeof t;(u==="undefined"||u==="boolean")&&(t=null);var i=!1;if(t===null)i=!0;else switch(u){case"bigint":case"string":case"number":i=!0;break;case"object":switch(t.$$typeof){case Gi:case Ay:i=!0;break;case Ko:return i=t._init,Ue(i(t._payload),l,e,n,a)}}if(i)return a=a(t),i=n===""?"."+Li(t,0):n,Xo(a)?(e="",i!=null&&(e=i.replace(Qo,"$&/")+"/"),Ue(a,l,e,"",function(o){return o})):a!=null&&(Ki(a)&&(a=wy(a,e+(a.key==null||t&&t.key===a.key?"":(""+a.key).replace(Qo,"$&/")+"/")+i)),l.push(a)),1;i=0;var c=n===""?".":n+":";if(Xo(t))for(var f=0;f{"use strict";Io.exports=$o()});var ts=nl(Qa=>{"use strict";var Xy=Symbol.for("react.transitional.element"),Qy=Symbol.for("react.fragment");function Po(t,l,e){var n=null;if(e!==void 0&&(n=""+e),l.key!==void 0&&(n=""+l.key),"key"in l){e={};for(var a in l)a!=="key"&&(e[a]=l[a])}else e=l;return l=e.ref,{$$typeof:Xy,type:t,key:n,ref:l!==void 0?l:null,props:e}}Qa.Fragment=Qy;Qa.jsx=Po;Qa.jsxs=Po});var es=nl((c1,ls)=>{"use strict";ls.exports=ts()});var ds=nl(W=>{"use strict";function ki(t,l){var e=t.length;t.push(l);t:for(;0>>1,a=t[n];if(0>>1;nZa(c,e))fZa(o,c)?(t[n]=o,t[f]=e,n=f):(t[n]=c,t[i]=e,n=i);else if(fZa(o,e))t[n]=o,t[f]=e,n=f;else break t}}return l}function Za(t,l){var e=t.sortIndex-l.sortIndex;return e!==0?e:t.id-l.id}W.unstable_now=void 0;typeof performance=="object"&&typeof performance.now=="function"?(ns=performance,W.unstable_now=function(){return ns.now()}):(Vi=Date,as=Vi.now(),W.unstable_now=function(){return Vi.now()-as});var ns,Vi,as,dl=[],Cl=[],Zy=1,Gt=null,vt=3,Fi=!1,Nn=!1,On=!1,$i=!1,cs=typeof setTimeout=="function"?setTimeout:null,fs=typeof clearTimeout=="function"?clearTimeout:null,us=typeof setImmediate<"u"?setImmediate:null;function Ka(t){for(var l=al(Cl);l!==null;){if(l.callback===null)Va(Cl);else if(l.startTime<=t)Va(Cl),l.sortIndex=l.expirationTime,ki(dl,l);else break;l=al(Cl)}}function Ii(t){if(On=!1,Ka(t),!Nn)if(al(dl)!==null)Nn=!0,Be||(Be=!0,He());else{var l=al(Cl);l!==null&&Pi(Ii,l.startTime-t)}}var Be=!1,Rn=-1,os=5,ss=-1;function rs(){return $i?!0:!(W.unstable_now()-sst&&rs());){var n=Gt.callback;if(typeof n=="function"){Gt.callback=null,vt=Gt.priorityLevel;var a=n(Gt.expirationTime<=t);if(t=W.unstable_now(),typeof a=="function"){Gt.callback=a,Ka(t),l=!0;break l}Gt===al(dl)&&Va(dl),Ka(t)}else Va(dl);Gt=al(dl)}if(Gt!==null)l=!0;else{var u=al(Cl);u!==null&&Pi(Ii,u.startTime-t),l=!1}}break t}finally{Gt=null,vt=e,Fi=!1}l=void 0}}finally{l?He():Be=!1}}}var He;typeof us=="function"?He=function(){us(Ji)}:typeof MessageChannel<"u"?(Wi=new MessageChannel,is=Wi.port2,Wi.port1.onmessage=Ji,He=function(){is.postMessage(null)}):He=function(){cs(Ji,0)};var Wi,is;function Pi(t,l){Rn=cs(function(){t(W.unstable_now())},l)}W.unstable_IdlePriority=5;W.unstable_ImmediatePriority=1;W.unstable_LowPriority=4;W.unstable_NormalPriority=3;W.unstable_Profiling=null;W.unstable_UserBlockingPriority=2;W.unstable_cancelCallback=function(t){t.callback=null};W.unstable_forceFrameRate=function(t){0>t||125n?(t.sortIndex=e,ki(Cl,t),al(dl)===null&&t===al(Cl)&&(On?(fs(Rn),Rn=-1):On=!0,Pi(Ii,e-n))):(t.sortIndex=a,ki(dl,t),Nn||Fi||(Nn=!0,Be||(Be=!0,He()))),t};W.unstable_shouldYield=rs;W.unstable_wrapCallback=function(t){var l=vt;return function(){var e=vt;vt=l;try{return t.apply(this,arguments)}finally{vt=e}}}});var ps=nl((o1,ms)=>{"use strict";ms.exports=ds()});var gs=nl(bt=>{"use strict";var Ky=Xa();function ys(t){var l="https://react.dev/errors/"+t;if(1{"use strict";function hs(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(hs)}catch(t){console.error(t)}}hs(),vs.exports=gs()});var Dp=nl(vi=>{"use strict";var ft=ps(),Kr=Xa(),Wy=Es();function v(t){var l="https://react.dev/errors/"+t;if(1Qe||(t.current=jc[Qe],jc[Qe]=null,Qe--)}function X(t,l){Qe++,jc[Qe]=t.current,t.current=l}var fl=ol(null),na=ol(null),Jl=ol(null),zu=ol(null);function Nu(t,l){switch(X(Jl,l),X(na,t),X(fl,null),l.nodeType){case 9:case 11:t=(t=l.documentElement)&&(t=t.namespaceURI)?Nr(t):0;break;default:if(t=l.tagName,l=l.namespaceURI)l=Nr(l),t=hp(l,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}rt(fl),X(fl,t)}function fn(){rt(fl),rt(na),rt(Jl)}function Lc(t){t.memoizedState!==null&&X(zu,t);var l=fl.current,e=hp(l,t.type);l!==e&&(X(na,t),X(fl,e))}function Ou(t){na.current===t&&(rt(fl),rt(na)),zu.current===t&&(rt(zu),pa._currentValue=pe)}var tc,xs;function se(t){if(tc===void 0)try{throw Error()}catch(e){var l=e.stack.trim().match(/\n( *(at )?)/);tc=l&&l[1]||"",xs=-1)":-1a||f[n]!==o[a]){var d=` +`+f[n].replace(" at new "," at ");return t.displayName&&d.includes("")&&(d=d.replace("",t.displayName)),d}while(1<=n&&0<=a);break}}}finally{lc=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?se(e):""}function Py(t,l){switch(t.tag){case 26:case 27:case 5:return se(t.type);case 16:return se("Lazy");case 13:return t.child!==l&&l!==null?se("Suspense Fallback"):se("Suspense");case 19:return se("SuspenseList");case 0:case 15:return ec(t.type,!1);case 11:return ec(t.type.render,!1);case 1:return ec(t.type,!0);case 31:return se("Activity");default:return""}}function _s(t){try{var l="",e=null;do l+=Py(t,e),e=t,t=t.return;while(t);return l}catch(n){return` +Error generating stack: `+n.message+` +`+n.stack}}var Yc=Object.prototype.hasOwnProperty,Df=ft.unstable_scheduleCallback,nc=ft.unstable_cancelCallback,t0=ft.unstable_shouldYield,l0=ft.unstable_requestPaint,Ct=ft.unstable_now,e0=ft.unstable_getCurrentPriorityLevel,Ir=ft.unstable_ImmediatePriority,Pr=ft.unstable_UserBlockingPriority,Ru=ft.unstable_NormalPriority,n0=ft.unstable_LowPriority,td=ft.unstable_IdlePriority,a0=ft.log,u0=ft.unstable_setDisableYieldValue,va=null,Ht=null;function Xl(t){if(typeof a0=="function"&&u0(t),Ht&&typeof Ht.setStrictMode=="function")try{Ht.setStrictMode(va,t)}catch{}}var Bt=Math.clz32?Math.clz32:f0,i0=Math.log,c0=Math.LN2;function f0(t){return t>>>=0,t===0?32:31-(i0(t)/c0|0)|0}var ka=256,Fa=262144,$a=4194304;function re(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function ei(t,l,e){var n=t.pendingLanes;if(n===0)return 0;var a=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var c=n&134217727;return c!==0?(n=c&~u,n!==0?a=re(n):(i&=c,i!==0?a=re(i):e||(e=c&~t,e!==0&&(a=re(e))))):(c=n&~u,c!==0?a=re(c):i!==0?a=re(i):e||(e=n&~t,e!==0&&(a=re(e)))),a===0?0:l!==0&&l!==a&&(l&u)===0&&(u=a&-a,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:a}function Ea(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function o0(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function ld(){var t=$a;return $a<<=1,($a&62914560)===0&&($a=4194304),t}function ac(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function ba(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function s0(t,l,e,n,a,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var c=t.entanglements,f=t.expirationTimes,o=t.hiddenUpdates;for(e=i&~e;0"u")return null;try{return t.activeElement||t.body}catch{return t.body}}var g0=/[\n"\\]/g;function Vt(t){return t.replace(g0,function(l){return"\\"+l.charCodeAt(0).toString(16)+" "})}function Qc(t,l,e,n,a,u,i,c){t.name="",i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?t.type=i:t.removeAttribute("type"),l!=null?i==="number"?(l===0&&t.value===""||t.value!=l)&&(t.value=""+Qt(l)):t.value!==""+Qt(l)&&(t.value=""+Qt(l)):i!=="submit"&&i!=="reset"||t.removeAttribute("value"),l!=null?Zc(t,i,Qt(l)):e!=null?Zc(t,i,Qt(e)):n!=null&&t.removeAttribute("value"),a==null&&u!=null&&(t.defaultChecked=!!u),a!=null&&(t.checked=a&&typeof a!="function"&&typeof a!="symbol"),c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?t.name=""+Qt(c):t.removeAttribute("name")}function sd(t,l,e,n,a,u,i,c){if(u!=null&&typeof u!="function"&&typeof u!="symbol"&&typeof u!="boolean"&&(t.type=u),l!=null||e!=null){if(!(u!=="submit"&&u!=="reset"||l!=null)){Xc(t);return}e=e!=null?""+Qt(e):"",l=l!=null?""+Qt(l):e,c||l===t.value||(t.value=l),t.defaultValue=l}n=n??a,n=typeof n!="function"&&typeof n!="symbol"&&!!n,t.checked=c?t.checked:!!n,t.defaultChecked=!!n,i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(t.name=i),Xc(t)}function Zc(t,l,e){l==="number"&&Du(t.ownerDocument)===t||t.defaultValue===""+e||(t.defaultValue=""+e)}function ln(t,l,e,n){if(t=t.options,l){l={};for(var a=0;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Vc=!1;if(Al)try{we={},Object.defineProperty(we,"passive",{get:function(){Vc=!0}}),window.addEventListener("test",we,we),window.removeEventListener("test",we,we)}catch{Vc=!1}var we,Ql=null,wf=null,pu=null;function yd(){if(pu)return pu;var t,l=wf,e=l.length,n,a="value"in Ql?Ql.value:Ql.textContent,u=a.length;for(t=0;t=Zn),Cs=" ",Hs=!1;function hd(t,l){switch(t){case"keyup":return Z0.indexOf(l.keyCode)!==-1;case"keydown":return l.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function vd(t){return t=t.detail,typeof t=="object"&&"data"in t?t.data:null}var Ve=!1;function V0(t,l){switch(t){case"compositionend":return vd(l);case"keypress":return l.which!==32?null:(Hs=!0,Cs);case"textInput":return t=l.data,t===Cs&&Hs?null:t;default:return null}}function J0(t,l){if(Ve)return t==="compositionend"||!Lf&&hd(t,l)?(t=yd(),pu=wf=Ql=null,Ve=!1,t):null;switch(t){case"paste":return null;case"keypress":if(!(l.ctrlKey||l.altKey||l.metaKey)||l.ctrlKey&&l.altKey){if(l.char&&1=l)return{node:e,offset:l-t};t=n}t:{for(;e;){if(e.nextSibling){e=e.nextSibling;break t}e=e.parentNode}e=void 0}e=Ls(e)}}function xd(t,l){return t&&l?t===l?!0:t&&t.nodeType===3?!1:l&&l.nodeType===3?xd(t,l.parentNode):"contains"in t?t.contains(l):t.compareDocumentPosition?!!(t.compareDocumentPosition(l)&16):!1:!1}function _d(t){t=t!=null&&t.ownerDocument!=null&&t.ownerDocument.defaultView!=null?t.ownerDocument.defaultView:window;for(var l=Du(t.document);l instanceof t.HTMLIFrameElement;){try{var e=typeof l.contentWindow.location.href=="string"}catch{e=!1}if(e)t=l.contentWindow;else break;l=Du(t.document)}return l}function Yf(t){var l=t&&t.nodeName&&t.nodeName.toLowerCase();return l&&(l==="input"&&(t.type==="text"||t.type==="search"||t.type==="tel"||t.type==="url"||t.type==="password")||l==="textarea"||t.contentEditable==="true")}var lg=Al&&"documentMode"in document&&11>=document.documentMode,Je=null,Jc=null,Vn=null,Wc=!1;function Gs(t,l,e){var n=e.window===e?e.document:e.nodeType===9?e:e.ownerDocument;Wc||Je==null||Je!==Du(n)||(n=Je,"selectionStart"in n&&Yf(n)?n={start:n.selectionStart,end:n.selectionEnd}:(n=(n.ownerDocument&&n.ownerDocument.defaultView||window).getSelection(),n={anchorNode:n.anchorNode,anchorOffset:n.anchorOffset,focusNode:n.focusNode,focusOffset:n.focusOffset}),Vn&&ia(Vn,n)||(Vn=n,n=ku(Jc,"onSelect"),0>=i,a-=i,ul=1<<32-Bt(l)+a|e<N?(D=_,_=null):D=_.sibling;var C=p(m,_,y[N],h);if(C===null){_===null&&(_=D);break}t&&_&&C.alternate===null&&l(m,_),s=u(C,s,N),x===null?b=C:x.sibling=C,x=C,_=D}if(N===y.length)return e(m,_),U&&vl(m,N),b;if(_===null){for(;NN?(D=_,_=null):D=_.sibling;var Ul=p(m,_,C.value,h);if(Ul===null){_===null&&(_=D);break}t&&_&&Ul.alternate===null&&l(m,_),s=u(Ul,s,N),x===null?b=Ul:x.sibling=Ul,x=Ul,_=D}if(C.done)return e(m,_),U&&vl(m,N),b;if(_===null){for(;!C.done;N++,C=y.next())C=g(m,C.value,h),C!==null&&(s=u(C,s,N),x===null?b=C:x.sibling=C,x=C);return U&&vl(m,N),b}for(_=n(_);!C.done;N++,C=y.next())C=r(_,m,N,C.value,h),C!==null&&(t&&C.alternate!==null&&_.delete(C.key===null?N:C.key),s=u(C,s,N),x===null?b=C:x.sibling=C,x=C);return t&&_.forEach(function(vy){return l(m,vy)}),U&&vl(m,N),b}function M(m,s,y,h){if(typeof y=="object"&&y!==null&&y.type===Xe&&y.key===null&&(y=y.props.children),typeof y=="object"&&y!==null){switch(y.$$typeof){case Wa:t:{for(var b=y.key;s!==null;){if(s.key===b){if(b=y.type,b===Xe){if(s.tag===7){e(m,s.sibling),h=a(s,y.props.children),h.return=m,m=h;break t}}else if(s.elementType===b||typeof b=="object"&&b!==null&&b.$$typeof===Bl&&de(b)===s.type){e(m,s.sibling),h=a(s,y.props),Cn(h,y),h.return=m,m=h;break t}e(m,s);break}else l(m,s);s=s.sibling}y.type===Xe?(h=ye(y.props.children,m.mode,h,y.key),h.return=m,m=h):(h=gu(y.type,y.key,y.props,null,m.mode,h),Cn(h,y),h.return=m,m=h)}return i(m);case Ln:t:{for(b=y.key;s!==null;){if(s.key===b)if(s.tag===4&&s.stateNode.containerInfo===y.containerInfo&&s.stateNode.implementation===y.implementation){e(m,s.sibling),h=a(s,y.children||[]),h.return=m,m=h;break t}else{e(m,s);break}else l(m,s);s=s.sibling}h=dc(y,m.mode,h),h.return=m,m=h}return i(m);case Bl:return y=de(y),M(m,s,y,h)}if(Yn(y))return E(m,s,y,h);if(qn(y)){if(b=qn(y),typeof b!="function")throw Error(v(150));return y=b.call(y),S(m,s,y,h)}if(typeof y.then=="function")return M(m,s,nu(y),h);if(y.$$typeof===bl)return M(m,s,eu(m,y),h);au(m,y)}return typeof y=="string"&&y!==""||typeof y=="number"||typeof y=="bigint"?(y=""+y,s!==null&&s.tag===6?(e(m,s.sibling),h=a(s,y),h.return=m,m=h):(e(m,s),h=rc(y,m.mode,h),h.return=m,m=h),i(m)):e(m,s)}return function(m,s,y,h){try{oa=0;var b=M(m,s,y,h);return an=null,b}catch(_){if(_===xn||_===fi)throw _;var x=qt(29,_,null,m.mode);return x.lanes=h,x.return=m,x}}}var Se=jd(!0),Ld=jd(!1),wl=!1;function kf(t){t.updateQueue={baseState:t.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function lf(t,l){t=t.updateQueue,l.updateQueue===t&&(l.updateQueue={baseState:t.baseState,firstBaseUpdate:t.firstBaseUpdate,lastBaseUpdate:t.lastBaseUpdate,shared:t.shared,callbacks:null})}function kl(t){return{lane:t,tag:0,payload:null,callback:null,next:null}}function Fl(t,l,e){var n=t.updateQueue;if(n===null)return null;if(n=n.shared,(H&2)!==0){var a=n.pending;return a===null?l.next=l:(l.next=a.next,a.next=l),n.pending=l,l=Uu(t),Rd(t,null,e),l}return ci(t,n,l,e),Uu(t)}function Wn(t,l,e){if(l=l.updateQueue,l!==null&&(l=l.shared,(e&4194048)!==0)){var n=l.lanes;n&=t.pendingLanes,e|=n,l.lanes=e,nd(t,e)}}function pc(t,l){var e=t.updateQueue,n=t.alternate;if(n!==null&&(n=n.updateQueue,e===n)){var a=null,u=null;if(e=e.firstBaseUpdate,e!==null){do{var i={lane:e.lane,tag:e.tag,payload:e.payload,callback:null,next:null};u===null?a=u=i:u=u.next=i,e=e.next}while(e!==null);u===null?a=u=l:u=u.next=l}else a=u=l;e={baseState:n.baseState,firstBaseUpdate:a,lastBaseUpdate:u,shared:n.shared,callbacks:n.callbacks},t.updateQueue=e;return}t=e.lastBaseUpdate,t===null?e.firstBaseUpdate=l:t.next=l,e.lastBaseUpdate=l}var ef=!1;function kn(){if(ef){var t=nn;if(t!==null)throw t}}function Fn(t,l,e,n){ef=!1;var a=t.updateQueue;wl=!1;var u=a.firstBaseUpdate,i=a.lastBaseUpdate,c=a.shared.pending;if(c!==null){a.shared.pending=null;var f=c,o=f.next;f.next=null,i===null?u=o:i.next=o,i=f;var d=t.alternate;d!==null&&(d=d.updateQueue,c=d.lastBaseUpdate,c!==i&&(c===null?d.firstBaseUpdate=o:c.next=o,d.lastBaseUpdate=f))}if(u!==null){var g=a.baseState;i=0,d=o=f=null,c=u;do{var p=c.lane&-536870913,r=p!==c.lane;if(r?(q&p)===p:(n&p)===p){p!==0&&p===rn&&(ef=!0),d!==null&&(d=d.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});t:{var E=t,S=c;p=l;var M=e;switch(S.tag){case 1:if(E=S.payload,typeof E=="function"){g=E.call(M,g,p);break t}g=E;break t;case 3:E.flags=E.flags&-65537|128;case 0:if(E=S.payload,p=typeof E=="function"?E.call(M,g,p):E,p==null)break t;g=V({},g,p);break t;case 2:wl=!0}}p=c.callback,p!==null&&(t.flags|=64,r&&(t.flags|=8192),r=a.callbacks,r===null?a.callbacks=[p]:r.push(p))}else r={lane:p,tag:c.tag,payload:c.payload,callback:c.callback,next:null},d===null?(o=d=r,f=g):d=d.next=r,i|=p;if(c=c.next,c===null){if(c=a.shared.pending,c===null)break;r=c,c=r.next,r.next=null,a.lastBaseUpdate=r,a.shared.pending=null}}while(!0);d===null&&(f=g),a.baseState=f,a.firstBaseUpdate=o,a.lastBaseUpdate=d,u===null&&(a.shared.lanes=0),ue|=i,t.lanes=i,t.memoizedState=g}}function Yd(t,l){if(typeof t!="function")throw Error(v(191,t));t.call(l)}function Gd(t,l){var e=t.callbacks;if(e!==null)for(t.callbacks=null,t=0;tu?u:8;var i=T.T,c={};T.T=c,fo(t,!1,l,e);try{var f=a(),o=T.S;if(o!==null&&o(c,f),f!==null&&typeof f=="object"&&typeof f.then=="function"){var d=sg(f,n);$n(t,l,d,wt(t))}else $n(t,l,n,wt(t))}catch(g){$n(t,l,{then:function(){},status:"rejected",reason:g},wt())}finally{B.p=u,i!==null&&c.types!==null&&(i.types=c.types),T.T=i}}function gg(){}function ff(t,l,e,n){if(t.tag!==5)throw Error(v(476));var a=dm(t).queue;rm(t,a,l,pe,e===null?gg:function(){return mm(t),e(n)})}function dm(t){var l=t.memoizedState;if(l!==null)return l;l={memoizedState:pe,baseState:pe,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nl,lastRenderedState:pe},next:null};var e={};return l.next={memoizedState:e,baseState:e,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Nl,lastRenderedState:e},next:null},t.memoizedState=l,t=t.alternate,t!==null&&(t.memoizedState=l),l}function mm(t){var l=dm(t);l.next===null&&(l=t.alternate.memoizedState),$n(t,l.next.queue,{},wt())}function co(){return yt(pa)}function pm(){return tt().memoizedState}function ym(){return tt().memoizedState}function hg(t){for(var l=t.return;l!==null;){switch(l.tag){case 24:case 3:var e=wt();t=kl(e);var n=Fl(l,t,e);n!==null&&(zt(n,l,e),Wn(n,l,e)),l={cache:Vf()},t.payload=l;return}l=l.return}}function vg(t,l,e){var n=wt();e={lane:n,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},di(t)?hm(l,e):(e=Xf(t,l,e,n),e!==null&&(zt(e,t,n),vm(e,l,n)))}function gm(t,l,e){var n=wt();$n(t,l,e,n)}function $n(t,l,e,n){var a={lane:n,revertLane:0,gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null};if(di(t))hm(l,a);else{var u=t.alternate;if(t.lanes===0&&(u===null||u.lanes===0)&&(u=l.lastRenderedReducer,u!==null))try{var i=l.lastRenderedState,c=u(i,e);if(a.hasEagerState=!0,a.eagerState=c,jt(c,i))return ci(t,l,a,0),G===null&&ii(),!1}catch{}if(e=Xf(t,l,a,n),e!==null)return zt(e,t,n),vm(e,l,n),!0}return!1}function fo(t,l,e,n){if(n={lane:2,revertLane:vo(),gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},di(t)){if(l)throw Error(v(479))}else l=Xf(t,e,n,2),l!==null&&zt(l,t,2)}function di(t){var l=t.alternate;return t===z||l!==null&&l===z}function hm(t,l){un=Lu=!0;var e=t.pending;e===null?l.next=l:(l.next=e.next,e.next=l),t.pending=l}function vm(t,l,e){if((e&4194048)!==0){var n=l.lanes;n&=t.pendingLanes,e|=n,l.lanes=e,nd(t,e)}}var ra={readContext:yt,use:si,useCallback:F,useContext:F,useEffect:F,useImperativeHandle:F,useLayoutEffect:F,useInsertionEffect:F,useMemo:F,useReducer:F,useRef:F,useState:F,useDebugValue:F,useDeferredValue:F,useTransition:F,useSyncExternalStore:F,useId:F,useHostTransitionStatus:F,useFormState:F,useActionState:F,useOptimistic:F,useMemoCache:F,useCacheRefresh:F};ra.useEffectEvent=F;var Em={readContext:yt,use:si,useCallback:function(t,l){return St().memoizedState=[t,l===void 0?null:l],t},useContext:yt,useEffect:er,useImperativeHandle:function(t,l,e){e=e!=null?e.concat([t]):null,Eu(4194308,4,im.bind(null,l,t),e)},useLayoutEffect:function(t,l){return Eu(4194308,4,t,l)},useInsertionEffect:function(t,l){Eu(4,2,t,l)},useMemo:function(t,l){var e=St();l=l===void 0?null:l;var n=t();if(xe){Xl(!0);try{t()}finally{Xl(!1)}}return e.memoizedState=[n,l],n},useReducer:function(t,l,e){var n=St();if(e!==void 0){var a=e(l);if(xe){Xl(!0);try{e(l)}finally{Xl(!1)}}}else a=l;return n.memoizedState=n.baseState=a,t={pending:null,lanes:0,dispatch:null,lastRenderedReducer:t,lastRenderedState:a},n.queue=t,t=t.dispatch=vg.bind(null,z,t),[n.memoizedState,t]},useRef:function(t){var l=St();return t={current:t},l.memoizedState=t},useState:function(t){t=uf(t);var l=t.queue,e=gm.bind(null,z,l);return l.dispatch=e,[t.memoizedState,e]},useDebugValue:uo,useDeferredValue:function(t,l){var e=St();return io(e,t,l)},useTransition:function(){var t=uf(!1);return t=rm.bind(null,z,t.queue,!0,!1),St().memoizedState=t,[!1,t]},useSyncExternalStore:function(t,l,e){var n=z,a=St();if(U){if(e===void 0)throw Error(v(407));e=e()}else{if(e=l(),G===null)throw Error(v(349));(q&127)!==0||Vd(n,l,e)}a.memoizedState=e;var u={value:e,getSnapshot:l};return a.queue=u,er(Wd.bind(null,n,u,t),[t]),n.flags|=2048,mn(9,{destroy:void 0},Jd.bind(null,n,u,e,l),null),e},useId:function(){var t=St(),l=G.identifierPrefix;if(U){var e=il,n=ul;e=(n&~(1<<32-Bt(n)-1)).toString(32)+e,l="_"+l+"R_"+e,e=Yu++,0<\/script>",u=u.removeChild(u.firstChild);break;case"select":u=typeof n.is=="string"?i.createElement("select",{is:n.is}):i.createElement("select"),n.multiple?u.multiple=!0:n.size&&(u.size=n.size);break;default:u=typeof n.is=="string"?i.createElement(a,{is:n.is}):i.createElement(a)}}u[mt]=l,u[Nt]=n;t:for(i=l.child;i!==null;){if(i.tag===5||i.tag===6)u.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===l)break t;for(;i.sibling===null;){if(i.return===null||i.return===l)break t;i=i.return}i.sibling.return=i.return,i=i.sibling}l.stateNode=u;t:switch(gt(u,a,n),a){case"button":case"input":case"select":case"textarea":n=!!n.autoFocus;break t;case"img":n=!0;break t;default:n=!1}n&&pl(l)}}return Z(l),xc(l,l.type,t===null?null:t.memoizedProps,l.pendingProps,e),null;case 6:if(t&&l.stateNode!=null)t.memoizedProps!==n&&pl(l);else{if(typeof n!="string"&&l.stateNode===null)throw Error(v(166));if(t=Jl.current,je(l)){if(t=l.stateNode,e=l.memoizedProps,n=null,a=pt,a!==null)switch(a.tag){case 27:case 5:n=a.memoizedProps}t[mt]=l,t=!!(t.nodeValue===e||n!==null&&n.suppressHydrationWarning===!0||gp(t.nodeValue,e)),t||ne(l,!0)}else t=Fu(t).createTextNode(n),t[mt]=l,l.stateNode=t}return Z(l),null;case 31:if(e=l.memoizedState,t===null||t.memoizedState!==null){if(n=je(l),e!==null){if(t===null){if(!n)throw Error(v(318));if(t=l.memoizedState,t=t!==null?t.dehydrated:null,!t)throw Error(v(557));t[mt]=l}else Ee(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Z(l),t=!1}else e=mc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=e),t=!0;if(!t)return l.flags&256?(Dt(l),l):(Dt(l),null);if((l.flags&128)!==0)throw Error(v(558))}return Z(l),null;case 13:if(n=l.memoizedState,t===null||t.memoizedState!==null&&t.memoizedState.dehydrated!==null){if(a=je(l),n!==null&&n.dehydrated!==null){if(t===null){if(!a)throw Error(v(318));if(a=l.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(v(317));a[mt]=l}else Ee(),(l.flags&128)===0&&(l.memoizedState=null),l.flags|=4;Z(l),a=!1}else a=mc(),t!==null&&t.memoizedState!==null&&(t.memoizedState.hydrationErrors=a),a=!0;if(!a)return l.flags&256?(Dt(l),l):(Dt(l),null)}return Dt(l),(l.flags&128)!==0?(l.lanes=e,l):(e=n!==null,t=t!==null&&t.memoizedState!==null,e&&(n=l.child,a=null,n.alternate!==null&&n.alternate.memoizedState!==null&&n.alternate.memoizedState.cachePool!==null&&(a=n.alternate.memoizedState.cachePool.pool),u=null,n.memoizedState!==null&&n.memoizedState.cachePool!==null&&(u=n.memoizedState.cachePool.pool),u!==a&&(n.flags|=2048)),e!==t&&e&&(l.child.flags|=8192),uu(l,l.updateQueue),Z(l),null);case 4:return fn(),t===null&&Eo(l.stateNode.containerInfo),Z(l),null;case 10:return Tl(l.type),Z(l),null;case 19:if(rt(P),n=l.memoizedState,n===null)return Z(l),null;if(a=(l.flags&128)!==0,u=n.rendering,u===null)if(a)Hn(n,!1);else{if($!==0||t!==null&&(t.flags&128)!==0)for(t=l.child;t!==null;){if(u=ju(t),u!==null){for(l.flags|=128,Hn(n,!1),t=u.updateQueue,l.updateQueue=t,uu(l,t),l.subtreeFlags=0,t=e,e=l.child;e!==null;)Dd(e,t),e=e.sibling;return X(P,P.current&1|2),U&&vl(l,n.treeForkCount),l.child}t=t.sibling}n.tail!==null&&Ct()>Zu&&(l.flags|=128,a=!0,Hn(n,!1),l.lanes=4194304)}else{if(!a)if(t=ju(u),t!==null){if(l.flags|=128,a=!0,t=t.updateQueue,l.updateQueue=t,uu(l,t),Hn(n,!0),n.tail===null&&n.tailMode==="hidden"&&!u.alternate&&!U)return Z(l),null}else 2*Ct()-n.renderingStartTime>Zu&&e!==536870912&&(l.flags|=128,a=!0,Hn(n,!1),l.lanes=4194304);n.isBackwards?(u.sibling=l.child,l.child=u):(t=n.last,t!==null?t.sibling=u:l.child=u,n.last=u)}return n.tail!==null?(t=n.tail,n.rendering=t,n.tail=t.sibling,n.renderingStartTime=Ct(),t.sibling=null,e=P.current,X(P,a?e&1|2:e&1),U&&vl(l,n.treeForkCount),t):(Z(l),null);case 22:case 23:return Dt(l),Ff(),n=l.memoizedState!==null,t!==null?t.memoizedState!==null!==n&&(l.flags|=8192):n&&(l.flags|=8192),n?(e&536870912)!==0&&(l.flags&128)===0&&(Z(l),l.subtreeFlags&6&&(l.flags|=8192)):Z(l),e=l.updateQueue,e!==null&&uu(l,e.retryQueue),e=null,t!==null&&t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),n=null,l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(n=l.memoizedState.cachePool.pool),n!==e&&(l.flags|=2048),t!==null&&rt(ge),null;case 24:return e=null,t!==null&&(e=t.memoizedState.cache),l.memoizedState.cache!==e&&(l.flags|=2048),Tl(at),Z(l),null;case 25:return null;case 30:return null}throw Error(v(156,l.tag))}function _g(t,l){switch(Kf(l),l.tag){case 1:return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 3:return Tl(at),fn(),t=l.flags,(t&65536)!==0&&(t&128)===0?(l.flags=t&-65537|128,l):null;case 26:case 27:case 5:return Ou(l),null;case 31:if(l.memoizedState!==null){if(Dt(l),l.alternate===null)throw Error(v(340));Ee()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 13:if(Dt(l),t=l.memoizedState,t!==null&&t.dehydrated!==null){if(l.alternate===null)throw Error(v(340));Ee()}return t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 19:return rt(P),null;case 4:return fn(),null;case 10:return Tl(l.type),null;case 22:case 23:return Dt(l),Ff(),t!==null&&rt(ge),t=l.flags,t&65536?(l.flags=t&-65537|128,l):null;case 24:return Tl(at),null;case 25:return null;default:return null}}function Dm(t,l){switch(Kf(l),l.tag){case 3:Tl(at),fn();break;case 26:case 27:case 5:Ou(l);break;case 4:fn();break;case 31:l.memoizedState!==null&&Dt(l);break;case 13:Dt(l);break;case 19:rt(P);break;case 10:Tl(l.type);break;case 22:case 23:Dt(l),Ff(),t!==null&&rt(ge);break;case 24:Tl(at)}}function Ma(t,l){try{var e=l.updateQueue,n=e!==null?e.lastEffect:null;if(n!==null){var a=n.next;e=a;do{if((e.tag&t)===t){n=void 0;var u=e.create,i=e.inst;n=u(),i.destroy=n}e=e.next}while(e!==a)}}catch(c){j(l,l.return,c)}}function ae(t,l,e){try{var n=l.updateQueue,a=n!==null?n.lastEffect:null;if(a!==null){var u=a.next;n=u;do{if((n.tag&t)===t){var i=n.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,a=l;var f=e,o=c;try{o()}catch(d){j(a,f,d)}}}n=n.next}while(n!==u)}}catch(d){j(l,l.return,d)}}function qm(t){var l=t.updateQueue;if(l!==null){var e=t.stateNode;try{Gd(l,e)}catch(n){j(t,t.return,n)}}}function Um(t,l,e){e.props=_e(t.type,t.memoizedProps),e.state=t.memoizedState;try{e.componentWillUnmount()}catch(n){j(t,l,n)}}function In(t,l){try{var e=t.ref;if(e!==null){switch(t.tag){case 26:case 27:case 5:var n=t.stateNode;break;case 30:n=t.stateNode;break;default:n=t.stateNode}typeof e=="function"?t.refCleanup=e(n):e.current=n}}catch(a){j(t,l,a)}}function cl(t,l){var e=t.ref,n=t.refCleanup;if(e!==null)if(typeof n=="function")try{n()}catch(a){j(t,l,a)}finally{t.refCleanup=null,t=t.alternate,t!=null&&(t.refCleanup=null)}else if(typeof e=="function")try{e(null)}catch(a){j(t,l,a)}else e.current=null}function Cm(t){var l=t.type,e=t.memoizedProps,n=t.stateNode;try{t:switch(l){case"button":case"input":case"select":case"textarea":e.autoFocus&&n.focus();break t;case"img":e.src?n.src=e.src:e.srcSet&&(n.srcset=e.srcSet)}}catch(a){j(t,t.return,a)}}function _c(t,l,e){try{var n=t.stateNode;Qg(n,t.type,e,l),n[Nt]=l}catch(a){j(t,t.return,a)}}function Hm(t){return t.tag===5||t.tag===3||t.tag===26||t.tag===27&&ce(t.type)||t.tag===4}function Tc(t){t:for(;;){for(;t.sibling===null;){if(t.return===null||Hm(t.return))return null;t=t.return}for(t.sibling.return=t.return,t=t.sibling;t.tag!==5&&t.tag!==6&&t.tag!==18;){if(t.tag===27&&ce(t.type)||t.flags&2||t.child===null||t.tag===4)continue t;t.child.return=t,t=t.child}if(!(t.flags&2))return t.stateNode}}function mf(t,l,e){var n=t.tag;if(n===5||n===6)t=t.stateNode,l?(e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e).insertBefore(t,l):(l=e.nodeType===9?e.body:e.nodeName==="HTML"?e.ownerDocument.body:e,l.appendChild(t),e=e._reactRootContainer,e!=null||l.onclick!==null||(l.onclick=Sl));else if(n!==4&&(n===27&&ce(t.type)&&(e=t.stateNode,l=null),t=t.child,t!==null))for(mf(t,l,e),t=t.sibling;t!==null;)mf(t,l,e),t=t.sibling}function Qu(t,l,e){var n=t.tag;if(n===5||n===6)t=t.stateNode,l?e.insertBefore(t,l):e.appendChild(t);else if(n!==4&&(n===27&&ce(t.type)&&(e=t.stateNode),t=t.child,t!==null))for(Qu(t,l,e),t=t.sibling;t!==null;)Qu(t,l,e),t=t.sibling}function Bm(t){var l=t.stateNode,e=t.memoizedProps;try{for(var n=t.type,a=l.attributes;a.length;)l.removeAttributeNode(a[0]);gt(l,n,e),l[mt]=t,l[Nt]=e}catch(u){j(t,t.return,u)}}var El=!1,nt=!1,Mc=!1,yr=typeof WeakSet=="function"?WeakSet:Set,ot=null;function Tg(t,l){if(t=t.containerInfo,bf=ti,t=_d(t),Yf(t)){if("selectionStart"in t)var e={start:t.selectionStart,end:t.selectionEnd};else t:{e=(e=t.ownerDocument)&&e.defaultView||window;var n=e.getSelection&&e.getSelection();if(n&&n.rangeCount!==0){e=n.anchorNode;var a=n.anchorOffset,u=n.focusNode;n=n.focusOffset;try{e.nodeType,u.nodeType}catch{e=null;break t}var i=0,c=-1,f=-1,o=0,d=0,g=t,p=null;l:for(;;){for(var r;g!==e||a!==0&&g.nodeType!==3||(c=i+a),g!==u||n!==0&&g.nodeType!==3||(f=i+n),g.nodeType===3&&(i+=g.nodeValue.length),(r=g.firstChild)!==null;)p=g,g=r;for(;;){if(g===t)break l;if(p===e&&++o===a&&(c=i),p===u&&++d===n&&(f=i),(r=g.nextSibling)!==null)break;g=p,p=g.parentNode}g=r}e=c===-1||f===-1?null:{start:c,end:f}}else e=null}e=e||{start:0,end:0}}else e=null;for(Sf={focusedElem:t,selectionRange:e},ti=!1,ot=l;ot!==null;)if(l=ot,t=l.child,(l.subtreeFlags&1028)!==0&&t!==null)t.return=l,ot=t;else for(;ot!==null;){switch(l=ot,u=l.alternate,t=l.flags,l.tag){case 0:if((t&4)!==0&&(t=l.updateQueue,t=t!==null?t.events:null,t!==null))for(e=0;e title"))),gt(u,n,e),u[mt]=t,st(u),n=u;break t;case"link":var i=wr("link","href",a).get(n+(e.href||""));if(i){for(var c=0;cM&&(i=M,M=S,S=i);var m=Ys(c,S),s=Ys(c,M);if(m&&s&&(r.rangeCount!==1||r.anchorNode!==m.node||r.anchorOffset!==m.offset||r.focusNode!==s.node||r.focusOffset!==s.offset)){var y=g.createRange();y.setStart(m.node,m.offset),r.removeAllRanges(),S>M?(r.addRange(y),r.extend(s.node,s.offset)):(y.setEnd(s.node,s.offset),r.addRange(y))}}}}for(g=[],r=c;r=r.parentNode;)r.nodeType===1&&g.push({element:r,left:r.scrollLeft,top:r.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;ce?32:e,T.T=null,e=gf,gf=null;var u=Il,i=Ml;if(ct=0,yn=Il=null,Ml=0,(H&6)!==0)throw Error(v(331));var c=H;if(H|=4,Jm(u.current),Zm(u,u.current,i,e),H=c,Aa(0,!1),Ht&&typeof Ht.onPostCommitFiberRoot=="function")try{Ht.onPostCommitFiberRoot(va,u)}catch{}return!0}finally{B.p=a,T.T=n,fp(t,l)}}function Er(t,l,e){l=Jt(e,l),l=sf(t.stateNode,l,2),t=Fl(t,l,2),t!==null&&(ba(t,2),sl(t))}function j(t,l,e){if(t.tag===3)Er(t,t,e);else for(;l!==null;){if(l.tag===3){Er(l,t,e);break}else if(l.tag===1){var n=l.stateNode;if(typeof l.type.getDerivedStateFromError=="function"||typeof n.componentDidCatch=="function"&&($l===null||!$l.has(n))){t=Jt(e,t),e=Tm(2),n=Fl(l,e,2),n!==null&&(Mm(e,n,l,t),ba(n,2),sl(n));break}}l=l.return}}function zc(t,l,e){var n=t.pingCache;if(n===null){n=t.pingCache=new zg;var a=new Set;n.set(l,a)}else a=n.get(l),a===void 0&&(a=new Set,n.set(l,a));a.has(e)||(yo=!0,a.add(e),t=qg.bind(null,t,l,e),l.then(t,t))}function qg(t,l,e){var n=t.pingCache;n!==null&&n.delete(l),t.pingedLanes|=t.suspendedLanes&e,t.warmLanes&=~e,G===t&&(q&e)===e&&($===4||$===3&&(q&62914560)===q&&300>Ct()-mi?(H&2)===0&&gn(t,0):go|=e,pn===q&&(pn=0)),sl(t)}function sp(t,l){l===0&&(l=ld()),t=ze(t,l),t!==null&&(ba(t,l),sl(t))}function Ug(t){var l=t.memoizedState,e=0;l!==null&&(e=l.retryLane),sp(t,e)}function Cg(t,l){var e=0;switch(t.tag){case 31:case 13:var n=t.stateNode,a=t.memoizedState;a!==null&&(e=a.retryLane);break;case 19:n=t.stateNode;break;case 22:n=t.stateNode._retryCache;break;default:throw Error(v(314))}n!==null&&n.delete(l),sp(t,e)}function Hg(t,l){return Df(t,l)}var Ju=null,Ge=null,vf=!1,Wu=!1,Nc=!1,Vl=0;function sl(t){t!==Ge&&t.next===null&&(Ge===null?Ju=Ge=t:Ge=Ge.next=t),Wu=!0,vf||(vf=!0,wg())}function Aa(t,l){if(!Nc&&Wu){Nc=!0;do for(var e=!1,n=Ju;n!==null;){if(!l)if(t!==0){var a=n.pendingLanes;if(a===0)var u=0;else{var i=n.suspendedLanes,c=n.pingedLanes;u=(1<<31-Bt(42|t)+1)-1,u&=a&~(i&~c),u=u&201326741?u&201326741|1:u?u|2:0}u!==0&&(e=!0,br(n,u))}else u=q,u=ei(n,n===G?u:0,n.cancelPendingCommit!==null||n.timeoutHandle!==-1),(u&3)===0||Ea(n,u)||(e=!0,br(n,u));n=n.next}while(e);Nc=!1}}function Bg(){rp()}function rp(){Wu=vf=!1;var t=0;Vl!==0&&Kg()&&(t=Vl);for(var l=Ct(),e=null,n=Ju;n!==null;){var a=n.next,u=dp(n,l);u===0?(n.next=null,e===null?Ju=a:e.next=a,a===null&&(Ge=e)):(e=n,(t!==0||(u&3)!==0)&&(Wu=!0)),n=a}ct!==0&&ct!==5||Aa(t,!1),Vl!==0&&(Vl=0)}function dp(t,l){for(var e=t.suspendedLanes,n=t.pingedLanes,a=t.expirationTimes,u=t.pendingLanes&-62914561;0c)break;var d=f.transferSize,g=f.initiatorType;d&&zr(g)&&(f=f.responseEnd,i+=d*(f"u"?null:document;function Sp(t,l,e){var n=Tn;if(n&&typeof l=="string"&&l){var a=Vt(l);a='link[rel="'+t+'"][href="'+a+'"]',typeof e=="string"&&(a+='[crossorigin="'+e+'"]'),Cr.has(a)||(Cr.add(a),t={rel:t,crossOrigin:e,href:l},n.querySelector(a)===null&&(l=n.createElement("link"),gt(l,"link",t),st(l),n.head.appendChild(l)))}}function th(t){Dl.D(t),Sp("dns-prefetch",t,null)}function lh(t,l){Dl.C(t,l),Sp("preconnect",t,l)}function eh(t,l,e){Dl.L(t,l,e);var n=Tn;if(n&&t&&l){var a='link[rel="preload"][as="'+Vt(l)+'"]';l==="image"&&e&&e.imageSrcSet?(a+='[imagesrcset="'+Vt(e.imageSrcSet)+'"]',typeof e.imageSizes=="string"&&(a+='[imagesizes="'+Vt(e.imageSizes)+'"]')):a+='[href="'+Vt(t)+'"]';var u=a;switch(l){case"style":u=hn(t);break;case"script":u=Mn(t)}$t.has(u)||(t=V({rel:"preload",href:l==="image"&&e&&e.imageSrcSet?void 0:t,as:l},e),$t.set(u,t),n.querySelector(a)!==null||l==="style"&&n.querySelector(za(u))||l==="script"&&n.querySelector(Na(u))||(l=n.createElement("link"),gt(l,"link",t),st(l),n.head.appendChild(l)))}}function nh(t,l){Dl.m(t,l);var e=Tn;if(e&&t){var n=l&&typeof l.as=="string"?l.as:"script",a='link[rel="modulepreload"][as="'+Vt(n)+'"][href="'+Vt(t)+'"]',u=a;switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":u=Mn(t)}if(!$t.has(u)&&(t=V({rel:"modulepreload",href:t},l),$t.set(u,t),e.querySelector(a)===null)){switch(n){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(e.querySelector(Na(u)))return}n=e.createElement("link"),gt(n,"link",t),st(n),e.head.appendChild(n)}}}function ah(t,l,e){Dl.S(t,l,e);var n=Tn;if(n&&t){var a=tn(n).hoistableStyles,u=hn(t);l=l||"default";var i=a.get(u);if(!i){var c={loading:0,preload:null};if(i=n.querySelector(za(u)))c.loading=5;else{t=V({rel:"stylesheet",href:t,"data-precedence":l},e),(e=$t.get(u))&&bo(t,e);var f=i=n.createElement("link");st(f),gt(f,"link",t),f._p=new Promise(function(o,d){f.onload=o,f.onerror=d}),f.addEventListener("load",function(){c.loading|=1}),f.addEventListener("error",function(){c.loading|=2}),c.loading|=4,_u(i,l,n)}i={type:"stylesheet",instance:i,count:1,state:c},a.set(u,i)}}}function uh(t,l){Dl.X(t,l);var e=Tn;if(e&&t){var n=tn(e).hoistableScripts,a=Mn(t),u=n.get(a);u||(u=e.querySelector(Na(a)),u||(t=V({src:t,async:!0},l),(l=$t.get(a))&&So(t,l),u=e.createElement("script"),st(u),gt(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},n.set(a,u))}}function ih(t,l){Dl.M(t,l);var e=Tn;if(e&&t){var n=tn(e).hoistableScripts,a=Mn(t),u=n.get(a);u||(u=e.querySelector(Na(a)),u||(t=V({src:t,async:!0,type:"module"},l),(l=$t.get(a))&&So(t,l),u=e.createElement("script"),st(u),gt(u,"link",t),e.head.appendChild(u)),u={type:"script",instance:u,count:1,state:null},n.set(a,u))}}function Hr(t,l,e,n){var a=(a=Jl.current)?$u(a):null;if(!a)throw Error(v(446));switch(t){case"meta":case"title":return null;case"style":return typeof e.precedence=="string"&&typeof e.href=="string"?(l=hn(e.href),e=tn(a).hoistableStyles,n=e.get(l),n||(n={type:"style",instance:null,count:0,state:null},e.set(l,n)),n):{type:"void",instance:null,count:0,state:null};case"link":if(e.rel==="stylesheet"&&typeof e.href=="string"&&typeof e.precedence=="string"){t=hn(e.href);var u=tn(a).hoistableStyles,i=u.get(t);if(i||(a=a.ownerDocument||a,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},u.set(t,i),(u=a.querySelector(za(t)))&&!u._p&&(i.instance=u,i.state.loading=5),$t.has(t)||(e={rel:"preload",as:"style",href:e.href,crossOrigin:e.crossOrigin,integrity:e.integrity,media:e.media,hrefLang:e.hrefLang,referrerPolicy:e.referrerPolicy},$t.set(t,e),u||ch(a,t,e,i.state))),l&&n===null)throw Error(v(528,""));return i}if(l&&n!==null)throw Error(v(529,""));return null;case"script":return l=e.async,e=e.src,typeof e=="string"&&l&&typeof l!="function"&&typeof l!="symbol"?(l=Mn(e),e=tn(a).hoistableScripts,n=e.get(l),n||(n={type:"script",instance:null,count:0,state:null},e.set(l,n)),n):{type:"void",instance:null,count:0,state:null};default:throw Error(v(444,t))}}function hn(t){return'href="'+Vt(t)+'"'}function za(t){return'link[rel="stylesheet"]['+t+"]"}function xp(t){return V({},t,{"data-precedence":t.precedence,precedence:null})}function ch(t,l,e,n){t.querySelector('link[rel="preload"][as="style"]['+l+"]")?n.loading=1:(l=t.createElement("link"),n.preload=l,l.addEventListener("load",function(){return n.loading|=1}),l.addEventListener("error",function(){return n.loading|=2}),gt(l,"link",e),st(l),t.head.appendChild(l))}function Mn(t){return'[src="'+Vt(t)+'"]'}function Na(t){return"script[async]"+t}function Br(t,l,e){if(l.count++,l.instance===null)switch(l.type){case"style":var n=t.querySelector('style[data-href~="'+Vt(e.href)+'"]');if(n)return l.instance=n,st(n),n;var a=V({},e,{"data-href":e.href,"data-precedence":e.precedence,href:null,precedence:null});return n=(t.ownerDocument||t).createElement("style"),st(n),gt(n,"style",a),_u(n,e.precedence,t),l.instance=n;case"stylesheet":a=hn(e.href);var u=t.querySelector(za(a));if(u)return l.state.loading|=4,l.instance=u,st(u),u;n=xp(e),(a=$t.get(a))&&bo(n,a),u=(t.ownerDocument||t).createElement("link"),st(u);var i=u;return i._p=new Promise(function(c,f){i.onload=c,i.onerror=f}),gt(u,"link",n),l.state.loading|=4,_u(u,e.precedence,t),l.instance=u;case"script":return u=Mn(e.src),(a=t.querySelector(Na(u)))?(l.instance=a,st(a),a):(n=e,(a=$t.get(u))&&(n=V({},e),So(n,a)),t=t.ownerDocument||t,a=t.createElement("script"),st(a),gt(a,"link",n),t.head.appendChild(a),l.instance=a);case"void":return null;default:throw Error(v(443,l.type))}else l.type==="stylesheet"&&(l.state.loading&4)===0&&(n=l.instance,l.state.loading|=4,_u(n,e.precedence,t));return l.instance}function _u(t,l,e){for(var n=e.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=n.length?n[n.length-1]:null,u=a,i=0;i title"):null)}function fh(t,l,e){if(e===1||l.itemProp!=null)return!1;switch(t){case"meta":case"title":return!0;case"style":if(typeof l.precedence!="string"||typeof l.href!="string"||l.href==="")break;return!0;case"link":if(typeof l.rel!="string"||typeof l.href!="string"||l.href===""||l.onLoad||l.onError)break;return l.rel==="stylesheet"?(t=l.disabled,typeof l.precedence=="string"&&t==null):!0;case"script":if(l.async&&typeof l.async!="function"&&typeof l.async!="symbol"&&!l.onLoad&&!l.onError&&l.src&&typeof l.src=="string")return!0}return!1}function _p(t){return!(t.type==="stylesheet"&&(t.state.loading&3)===0)}function oh(t,l,e,n){if(e.type==="stylesheet"&&(typeof n.media!="string"||matchMedia(n.media).matches!==!1)&&(e.state.loading&4)===0){if(e.instance===null){var a=hn(n.href),u=l.querySelector(za(a));if(u){l=u._p,l!==null&&typeof l=="object"&&typeof l.then=="function"&&(t.count++,t=Iu.bind(t),l.then(t,t)),e.state.loading|=4,e.instance=u,st(u);return}u=l.ownerDocument||l,n=xp(n),(a=$t.get(a))&&bo(n,a),u=u.createElement("link"),st(u);var i=u;i._p=new Promise(function(c,f){i.onload=c,i.onerror=f}),gt(u,"link",n),e.instance=u}t.stylesheets===null&&(t.stylesheets=new Map),t.stylesheets.set(e,l),(l=e.state.preload)&&(e.state.loading&3)===0&&(t.count++,e=Iu.bind(t),l.addEventListener("load",e),l.addEventListener("error",e))}}var qc=0;function sh(t,l){return t.stylesheets&&t.count===0&&Mu(t,t.stylesheets),0qc?50:800)+l);return t.unsuspend=e,function(){t.unsuspend=null,clearTimeout(n),clearTimeout(a)}}:null}function Iu(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Mu(this,this.stylesheets);else if(this.unsuspend){var t=this.unsuspend;this.unsuspend=null,t()}}}var Pu=null;function Mu(t,l){t.stylesheets=null,t.unsuspend!==null&&(t.count++,Pu=new Map,l.forEach(rh,t),Pu=null,Iu.call(t))}function rh(t,l){if(!(l.state.loading&4)){var e=Pu.get(t);if(e)var n=e.get(null);else{e=new Map,Pu.set(t,e);for(var a=t.querySelectorAll("link[data-precedence],style[data-precedence]"),u=0;u{"use strict";function qp(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(qp)}catch(t){console.error(t)}}qp(),Up.exports=Dp()});var I=ji(Xa(),1),$h=ji(es(),1),py=ji(Cp(),1);var jo={};Ty(jo,{EXPLAIN_DIAGNOSTIC_THRESHOLDS:()=>Ui,EXPLAIN_METRIC_KINDS:()=>Ro,NodeIdAllocator:()=>Yt,buildSqliteTree:()=>Mi,computeExplainMetrics:()=>Oi,countDiagnosticsBySeverity:()=>sy,detectFormat:()=>Jp,detectFormatFor:()=>Ni,explainEngineFromDriverName:()=>Vp,findExplainNode:()=>Bo,flattenExplainNodes:()=>De,formatCost:()=>ay,formatRatio:()=>Ua,formatRows:()=>fe,formatTime:()=>uy,getAvailableMetricKinds:()=>Uo,getDefaultMetricKind:()=>ly,getExplainDriverLegend:()=>cy,getExplainParser:()=>Re,getExplainPlanStats:()=>ry,getExplainPlanSummary:()=>iy,getHeatBarClass:()=>ny,getMaxCost:()=>Co,getMaxTime:()=>Ho,getMetricMax:()=>ty,getMetricValue:()=>Do,getNodeCostStyle:()=>ey,getNodeDiagnostics:()=>wo,getNodeMetrics:()=>Pp,getPlanDiagnostics:()=>fy,getRowEstimateRatio:()=>Ri,getWorstSeverity:()=>oy,hasAnalyzeDataRecursive:()=>An,isMetricAvailable:()=>qo,listExplainParsers:()=>qa,parseExplain:()=>Fp,parseExplainFor:()=>zo,parseMysqlJson:()=>Si,parseMysqlTabularRows:()=>xi,parseMysqlText:()=>Ra,parsePostgresJson:()=>_i,parsePostgresText:()=>Ti,parseRawExplain:()=>No,parseSqliteEqpRows:()=>Ai,registerExplainParser:()=>Qp,resolveExplainOutput:()=>Ip,unregisterExplainParser:()=>Zp,withSourceLabel:()=>$p});var Yt=class{counter=0;next(){let l=`node_${this.counter}`;return this.counter+=1,l}};function ht(t,l={}){return{id:t,node_type:"Unknown",relation:null,startup_cost:null,total_cost:null,plan_rows:null,actual_rows:null,actual_time_ms:null,actual_loops:null,buffers_hit:null,buffers_read:null,filter:null,index_condition:null,join_type:null,hash_condition:null,extra:{},children:[],...l}}function An(t){return t.actual_rows!==null||t.actual_time_ms!==null?!0:t.children.some(An)}function lt(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)?t:null}function It(t){return Array.isArray(t)?t:null}function xt(t){return typeof t=="string"?t:null}function J(t){return typeof t=="number"&&Number.isFinite(t)?t:null}function Oe(t){let l=J(t);if(l!==null)return l;let e=xt(t);if(e===null||e.trim()==="")return null;let n=Number(e);return Number.isFinite(n)?n:null}function rl(t){let l=J(t);return l===null?null:Math.trunc(l)}function Si(t){let l;try{l=JSON.parse(t)}catch(c){throw new Error(`Failed to parse EXPLAIN JSON: ${String(c)}`)}let e=lt(l),n=e?.query_block;if(n===void 0)throw new Error("EXPLAIN JSON missing 'query_block' key");let a=new Yt,u=it(n,a),i=J(lt(e?.query_optimization)?.r_total_time_ms);return{root:u,planning_time_ms:i,execution_time_ms:null,original_query:"",driver:"mysql",has_analyze_data:An(u),raw_output:t}}function Ra(t){if(t.trim()==="")throw new Error("EXPLAIN ANALYZE returned no output");let l=new Yt,e=bh(t,l);return{root:e,planning_time_ms:null,execution_time_ms:null,original_query:"",driver:"mysql",has_analyze_data:An(e),raw_output:t}}function Ei(t,l){let e=l.next(),n=lt(t)??{},a={};for(let c of["sort_key","r_sort_mode","r_buffer_size","r_limit","r_used_priority_queue"])n[c]!==void 0&&(a[c]=n[c]);let u=[];n.temporary_table!==void 0&&u.push(bi(n.temporary_table,l));let i=It(n.nested_loop);if(i!==null)for(let c of i)u.push(it(c,l));return n.ordering_operation!==void 0&&u.push(it(n.ordering_operation,l)),n.table!==void 0&&n.nested_loop===void 0&&n.temporary_table===void 0&&u.push(it(n,l)),ht(e,{node_type:"Filesort",actual_rows:J(n.r_output_rows),actual_time_ms:J(n.r_total_time_ms),actual_loops:rl(n.r_loops),extra:a,children:u})}function bi(t,l){let e=l.next(),n=lt(t)??{},a=[],u=It(n.nested_loop);if(u!==null)for(let i of u)a.push(it(i,l));return n.filesort!==void 0&&a.push(Ei(n.filesort,l)),ht(e,{node_type:"Temporary Table",children:a})}function Eh(t,l){let e=l.next(),n=lt(t)??{},a={};n.r_hit_ratio!==void 0&&(a.r_hit_ratio=n.r_hit_ratio);let u=[];return n.query_block!==void 0&&u.push(it(n.query_block,l)),ht(e,{node_type:"Subquery Cache",actual_loops:rl(n.r_loops),extra:a,children:u})}function ql(t,l,e){let n=e.next(),a=lt(t)??{},u=[];a.table!==void 0&&u.push(it(a,e));let i=It(a.nested_loop);if(i!==null)for(let o of i)u.push(it(o,e));lt(a.filesort)!==null&&u.push(Ei(a.filesort,e)),a.temporary_table!==void 0&&u.push(bi(a.temporary_table,e)),a.query_block!==void 0&&u.push(it(a.query_block,e));let c=It(a.query_specifications);if(c!==null)for(let o of c)u.push(it(o,e));a.ordering_operation!==void 0&&u.push(it(a.ordering_operation,e)),a.grouping_operation!==void 0&&u.push(it(a.grouping_operation,e));let f=It(a.attached_subqueries);if(f!==null)for(let o of f)u.push(it(o,e));return ht(n,{node_type:l,total_cost:Oe(a.cost),actual_rows:J(a.r_rows)??J(a.r_output_rows),actual_time_ms:J(a.r_total_time_ms),actual_loops:rl(a.r_loops),children:u})}var Hp={ALL:"Full Table Scan",index:"Index Scan",range:"Range Scan",ref:"Index Lookup",eq_ref:"Unique Index Lookup",const:"Const Lookup",system:"Const Lookup",fulltext:"Fulltext Search"};function it(t,l){let e=l.next(),n=lt(t)??{},a,u=null,i=null,c=null,f=null,o=null,d=lt(n.table);if(d!==null){let b=xt(d.access_type)??"ALL";a=Hp[b]??b,u=xt(d.table_name),i=J(d.rows_examined_per_scan)??J(d.rows);let x=lt(d.cost_info);c=Oe(x?.read_cost),f=Oe(x?.prefix_cost)??c??Oe(d.cost),o=xt(d.attached_condition)}else{lt(n)?.using_filesort===!0?a="Filesort":n.grouping_operation!==void 0?a="Group":n.duplicates_removal!==void 0?a="Duplicate Removal":n.having_condition!==void 0?a="Having Filter":n.window_functions_computation!==void 0?a="Window Functions":a="Query Block";let b=lt(n.cost_info);f=Oe(b?.query_cost??b?.sort_cost??b?.prefix_cost)??Oe(n.cost),o=xt(n.having_condition)}let g=["access_type","table_name","rows_examined_per_scan","rows","cost_info","attached_condition","key","possible_keys","used_key_parts"],p={};if(d!==null){for(let[x,_]of Object.entries(d))g.includes(x)||(p[x]=_);let b=xt(d.key);b!==null&&(p.key=b)}let r=[],E=It(n.nested_loop);if(E!==null)for(let b of E){let x=lt(b)??{};x.table!==void 0?r.push(it(x,l)):x.read_sorted_file!==void 0?r.push(ql(x.read_sorted_file,"Read Sorted File",l)):lt(x.filesort)!==null?r.push(Ei(x.filesort,l)):x.temporary_table!==void 0?r.push(bi(x.temporary_table,l)):x.materialized!==void 0?r.push(ql(x.materialized,"Materialized Subquery",l)):x.buffer_result!==void 0&&r.push(ql(x.buffer_result,"Buffer Result",l))}n.ordering_operation!==void 0&&r.push(it(n.ordering_operation,l)),n.grouping_operation!==void 0&&r.push(it(n.grouping_operation,l)),n.duplicates_removal!==void 0&&r.push(it(n.duplicates_removal,l));for(let b of["optimized_away_subqueries","attached_subqueries"]){let x=It(n[b]);if(x!==null)for(let _ of x)r.push(it(_,l))}let S=It(n.subqueries);if(S!==null)for(let b of S){let x=lt(b)??{};x.subquery_cache!==void 0?r.push(Eh(x.subquery_cache,l)):(x.query_block!==void 0||x.table!==void 0)&&r.push(it(x,l))}lt(n.filesort)!==null&&r.push(Ei(n.filesort,l)),n.temporary_table!==void 0&&r.push(bi(n.temporary_table,l)),n.materialized!==void 0&&r.push(ql(n.materialized,"Materialized Subquery",l)),n.union_result!==void 0&&r.push(ql(n.union_result,"Union Result",l)),n.buffer_result!==void 0&&r.push(ql(n.buffer_result,"Buffer Result",l)),n.window_functions_computation!==void 0&&r.push(ql(n.window_functions_computation,"Window Functions",l)),n.expression_cache!==void 0&&r.push(ql(n.expression_cache,"Expression Cache",l)),n.read_sorted_file!==void 0&&r.push(ql(n.read_sorted_file,"Read Sorted File",l));let M=It(n.query_specifications);if(M!==null)for(let b of M)r.push(it(b,l));let m=d!==null?xt(d.key):null,s=J(d?.r_total_time_ms);if(s===null&&d!==null){let b=J(d.r_table_time_ms),x=J(d.r_other_time_ms);b!==null&&(s=x!==null?b+x:b)}s===null&&(s=J(n.r_total_time_ms));let y=J(d?.r_rows)??J(n.r_rows),h=rl(d?.r_loops)??rl(n.r_loops);return ht(e,{node_type:a,relation:u,startup_cost:c,total_cost:f,plan_rows:i,actual_rows:y,actual_time_ms:s,actual_loops:h,filter:o,index_condition:m,extra:p,children:r})}function bh(t,l){let e=[];for(let a of t.split(` +`)){let u=a.replace(/\s+$/,"");if(u==="")continue;let i=u.length-u.trimStart().length,c=Math.floor(i/4),f=u.trimStart();if(!f.startsWith("-> "))continue;let o=f.slice(3),d=o.indexOf(" ("),g=d===-1?o:o.slice(0,d),p=d===-1?"":o.slice(d),[r,E]=Sh(p),[S,M,m]=xh(p),[s,y,h,b,x]=_h(g);e.push({depth:c,node_type:s,relation:y,filter:h,index_condition:b,join_type:x,est_cost:r,est_rows:E,actual_time_ms:S,actual_rows:M,actual_loops:m})}if(e.length===0)return ht(l.next(),{node_type:"Query"});let[n]=Bp(e,0,-1,l);return n.length===1?n[0]:ht(l.next(),{node_type:"Query",children:n})}function Bp(t,l,e,n){let a=[],u=l;for(;uLp(i,l));return ht(e,{node_type:xt(n["Node Type"])??"Unknown",relation:xt(n["Relation Name"]),startup_cost:J(n["Startup Cost"]),total_cost:J(n["Total Cost"]),plan_rows:J(n["Plan Rows"]),actual_rows:J(n["Actual Rows"]),actual_time_ms:J(n["Actual Total Time"]),actual_loops:rl(n["Actual Loops"]),buffers_hit:rl(n["Shared Hit Blocks"]),buffers_read:rl(n["Shared Read Blocks"]),filter:xt(n.Filter),index_condition:xt(n["Index Cond"]),join_type:xt(n["Join Type"]),hash_condition:xt(n["Hash Cond"]),extra:a,children:u})}function Ti(t){let l=null,e=null,n=!1,a=new Yt,u=[],i=null,c=o=>{let d=u[u.length-1];d!==void 0?d.node.children.push(o):i===null?i=o:i.children.push(o)},f=o=>{for(;u.length>0&&u[u.length-1].indent>=o;){let d=u.pop();d!==void 0&&c(d.node)}};for(let o of t.split(` +`)){let d=o.replace(/\s+$/,"");if(d.trim()===""||Nh(d))continue;let g=Oh(d),p=d.trimStart(),r=wp(p,"Planning Time:");if(r!==null){l=r;continue}let E=wp(p,"Execution Time:");if(E!==null){e=E;continue}let S=Rh(p,a);if(S!==null)(S.actual_rows!==null||S.actual_time_ms!==null)&&(n=!0),f(g),u.push({indent:g,node:S});else{let M=u[u.length-1];M!==void 0&&Ch(M.node,p)}}if(f(0),i===null)throw new Error("No plan nodes found in EXPLAIN text output");return{root:i,planning_time_ms:l,execution_time_ms:e,original_query:"",driver:"postgres",has_analyze_data:n,raw_output:t}}function Nh(t){let l=t.trim();return!!(l.toUpperCase()==="QUERY PLAN"||l!==""&&[...l].every(e=>e==="-")||l.startsWith("(")&&(l.endsWith("rows)")||l.endsWith("row)")))}function Oh(t){let l=0;for(let e of t)if(/\s/.test(e))l+=1;else break;return l}function wp(t,l){if(!t.startsWith(l))return null;let e=t.slice(l.length).trim();e.endsWith("ms")&&(e=e.slice(0,-2).trim());let n=Number(e);return e!==""&&Number.isFinite(n)?n:null}function Rh(t,l){let e=t.startsWith("->")?t.slice(2).trimStart():t,n=e.indexOf("(cost=");if(n===-1)return null;let a=e.slice(0,n).trim(),u=e.slice(n),i=jp(u);if(i===null)return null;let[c,f,o]=qh(i.inner),d=i.rest.trimStart(),g=jp(d),[p,r,E]=g!==null?Uh(g.inner):[null,null,null],[S,M]=Dh(a);return ht(l.next(),{node_type:S,relation:M,startup_cost:c,total_cost:f,plan_rows:o,actual_rows:r,actual_time_ms:p,actual_loops:E})}function Dh(t){let l=t.lastIndexOf(" on ");if(l===-1)return[t.trim(),null];let e=t.slice(0,l).trim(),n=t.slice(l+4).trim();return n===""?[e,null]:[e,n]}function jp(t){if(!t.startsWith("("))return null;let l=0;for(let e=0;el===0?g.parent===0&&g.id===0:g.id===l),[a,u,i]=n!==void 0?Yp(n.detail):["Query Plan",null,null],c=e.next(),f=n?.id??0,d=t.filter(g=>g.parent===f&&g.id!==f).map(g=>g.id).map(g=>{let p=t.find(s=>s.id===g),[r,E,S]=p!==void 0?Yp(p.detail):["Unknown",null,null],M=e.next(),m=t.filter(s=>s.parent===g).map(s=>Mi(t,s.id,e));return ht(M,{node_type:r,relation:E,index_condition:S,children:m})});return ht(c,{node_type:a,relation:u,index_condition:i,children:d})}function Ai(t){if(t.length===0)throw new Error("EXPLAIN QUERY PLAN returned no output");let l=new Yt,e=Mi(t,0,l),n=t.map(a=>`${a.id}|${a.parent}|${a.detail}`).join(` +`);return{root:e,planning_time_ms:null,execution_time_ms:null,original_query:"",driver:"sqlite",has_analyze_data:!1,raw_output:n}}function Yp(t){let l=t.toUpperCase();if(l.startsWith("SCAN")){let n=t.split(" ")[1]??null,a=null;return l.includes("USING INDEX")?a=t.slice(t.indexOf("USING INDEX")+12).trim():l.includes("USING COVERING INDEX")&&(a=t.slice(t.indexOf("USING COVERING INDEX")+21).trim()),["Scan",n,a]}if(l.startsWith("SEARCH")){let n=t.split(" ")[1]??null,a=null;return l.includes("USING INDEX")?a=t.slice(t.indexOf("USING INDEX")+12).trim():l.includes("USING INTEGER PRIMARY KEY")?a="PRIMARY KEY":l.includes("USING COVERING INDEX")&&(a=t.slice(t.indexOf("USING COVERING INDEX")+21).trim()),["Search",n,a]}return l.includes("TEMP B-TREE")?["Sort",null,null]:l.startsWith("CO-ROUTINE")?["Co-routine",null,null]:l.startsWith("COMPOUND SUBQUERIES")?["Compound Subquery",null,null]:l.startsWith("MATERIALIZE")?["Materialize",null,null]:[t,null,null]}function Gp(t,l){let e;try{e=JSON.parse(t)}catch(n){throw new Error(`Failed to parse EXPLAIN rows: ${String(n)}`)}if(!Array.isArray(e))throw new Error(`EXPLAIN rows payload for '${l}' must be a JSON array`);return e}var Ao=Object.freeze([Object.freeze({engine:"postgres",format:"postgres-json",parse:_i}),Object.freeze({engine:"postgres",format:"postgres-text",parse:Ti}),Object.freeze({engine:"mysql",format:"mysql-json",parse:Si}),Object.freeze({engine:"mysql",format:"mysql-text",parse:Ra}),Object.freeze({engine:"mysql",format:"mysql-analyze-text",parse:Ra}),Object.freeze({engine:"mysql",format:"mysql-tabular-rows",parse:t=>xi(Gp(t,"mysql-tabular-rows"))}),Object.freeze({engine:"sqlite",format:"sqlite-eqp-rows",parse:t=>Ai(Gp(t,"sqlite-eqp-rows"))})]);var Xp=new Map(Ao.map(t=>[t.format,t])),Da=new Map,zi=[];function Qp(t){Hh(t),Re(t.format)!==null?console.warn(`EXPLAIN parser format '${t.format}' is already registered; replacing it.`):zi.push(t.format),Da.set(t.format,t)}function Zp(t){if(!Da.delete(t)||Xp.has(t))return;let l=zi.indexOf(t);l!==-1&&zi.splice(l,1)}function Re(t){return Da.get(t)??Xp.get(t)??null}function qa(){let t=Ao.map(l=>Da.get(l.format)??l);for(let l of zi){let e=Da.get(l);e!==void 0&&t.push(e)}return Object.freeze(t)}function Hh(t){if(typeof t!="object"||t===null||typeof t.engine!="string"||t.engine.trim()===""||typeof t.format!="string"||t.format.trim()===""||typeof t.parse!="function")throw new TypeError("EXPLAIN parser registration requires non-empty engine and format strings and a parse function")}var Bh=new Set(["postgres-json","postgres-text","mysql-json","mysql-text"]);function wh(t){let l=Re(t);if(l===null)throw new Error(`No parser registered for format '${t}'`);return l}function Vp(t){let l=t.trim().toLowerCase();switch(l){case"postgres":case"postgresql":case"pg":return"postgres";case"mysql":case"mariadb":return"mysql";case"sqlite":case"sqlite3":return"sqlite"}return qa().find(n=>n.engine.trim().toLowerCase()===l)?.engine??null}async function Jp(t){return await Ni(t,null)}async function Ni(t,l){switch(l){case"postgres":return Promise.resolve(jh(t));case"mysql":if(kp(t))return Promise.resolve("mysql-json");if(t.trim()==="")throw new Error("Unsupported EXPLAIN file format: input is empty");return Promise.resolve("mysql-text");case"sqlite":throw new Error("SQLite EXPLAIN QUERY PLAN has no text form here: pass its (id, parent, detail) rows to buildSqliteTree");case null:{let e=Wp(t);if(e!==null)return e;let n=await Kp(t,null);if(n!==null)return n;throw new Error("Unsupported EXPLAIN file format: expected Postgres JSON or text output")}default:{let e=await Kp(t,l);if(e!==null)return e;throw new Error(`Unsupported EXPLAIN file format for engine '${l}'`)}}}function jh(t){let l=Wp(t);if(l!==null)return l;throw new Error("Unsupported EXPLAIN file format: expected Postgres JSON or text output")}function Wp(t){return kp(t)?"postgres-json":Lh(t)?"postgres-text":null}async function Kp(t,l){let e=l?.trim().toLowerCase()??null;for(let n of qa())if(!(e===null&&Bh.has(n.format))&&!(e!==null&&n.engine.trim().toLowerCase()!==e)&&n.sniff!==void 0)try{if(await n.sniff(t))return n.format}catch{}return null}function kp(t){let l=t.trimStart();return l.startsWith("[")||l.startsWith("{")}function Lh(t){return t.split(` +`).some(l=>l.includes("(cost=")&&l.includes("width="))}function Fp(t){return zo(t,null)}async function zo(t,l){let e=await Ni(t,l);return await wh(e).parse(t)}function $p(t,l){return t.original_query===""?{...t,original_query:`-- loaded from ${l}`}:t}async function No(t){let l=Re(t.format);if(l===null)throw new Error(`No EXPLAIN parser registered for format '${t.format}' (engine '${t.engine}'). Import the parser package for '${t.engine}' before parsing.`);return{...await l.parse(t.payload),driver:t.engine,original_query:t.original_query}}function Ip(t){return t.kind==="plan"?Promise.resolve(t.plan):No(t.raw)}var Ro=["time","rows","cost","buffers"];function Yh(t){return t.actual_loops!=null&&t.actual_loops>0?t.actual_loops:1}function Gh(t){let l=t.extra["Parent Relationship"];return l==="InitPlan"||l==="SubPlan"}function Xh(t){return t.buffers_hit==null&&t.buffers_read==null?null:(t.buffers_hit??0)+(t.buffers_read??0)}function Oo(t,l){return t==null?null:Math.max(0,t-l)}function Oi(t){let l=new Map,e=[],n=1;function a(d,g){let p={nodeId:d.id,index:n,depth:g,inclusiveTimeMs:null,exclusiveTimeMs:null,totalCost:d.total_cost,exclusiveCost:null,totalRows:null,inclusiveBuffers:Xh(d),exclusiveBuffers:null,timeShare:null,neverExecuted:d.actual_loops===0};n+=1,l.set(d.id,p),e.push(p);let r=0,E=0,S=0;for(let m of d.children){let s=a(m,g+1);E+=s.cost,S+=s.buffers,Gh(m)||(r+=s.timeMs)}let M=Yh(d);return p.inclusiveTimeMs=d.actual_time_ms!=null?d.actual_time_ms*M:null,p.exclusiveTimeMs=Oo(p.inclusiveTimeMs,r),p.exclusiveCost=Oo(p.totalCost,E),p.exclusiveBuffers=Oo(p.inclusiveBuffers,S),p.totalRows=d.actual_rows!=null?d.actual_rows*M:null,{timeMs:p.inclusiveTimeMs??0,cost:p.totalCost??0,buffers:p.inclusiveBuffers??0}}a(t.root,0);let u=0,i=0,c=0,f=0,o=0;for(let d of e)u=Math.max(u,d.exclusiveTimeMs??0),i=Math.max(i,d.exclusiveCost??0),c=Math.max(c,d.totalRows??0),f=Math.max(f,d.exclusiveBuffers??0),o+=d.exclusiveTimeMs??0;if(o>0)for(let d of e)d.exclusiveTimeMs!=null&&(d.timeShare=d.exclusiveTimeMs/o);return{byId:l,order:e,maxExclusiveTimeMs:u,maxExclusiveCost:i,maxTotalRows:c,maxExclusiveBuffers:f,totalExclusiveTimeMs:o}}function Pp(t,l){return l?t.byId.get(l)??null:null}function Do(t,l){switch(l){case"time":return t.exclusiveTimeMs;case"rows":return t.totalRows;case"cost":return t.exclusiveCost;case"buffers":return t.exclusiveBuffers}}function ty(t,l){switch(l){case"time":return t.maxExclusiveTimeMs;case"rows":return t.maxTotalRows;case"cost":return t.maxExclusiveCost;case"buffers":return t.maxExclusiveBuffers}}function qo(t,l){return t.order.some(e=>Do(e,l)!=null)}function Uo(t){return Ro.filter(l=>qo(t,l))}function ly(t){if(t.maxExclusiveTimeMs>0)return"time";let l=Uo(t);return l.includes("cost")?"cost":l[0]??null}function ey(t,l){if(l<=0)return{border:"border-l-green-500",headerBg:"bg-green-950/30"};let e=t/l;return e<.2?{border:"border-l-green-500",headerBg:"bg-green-950/30"}:e<.6?{border:"border-l-yellow-500",headerBg:"bg-yellow-950/30"}:{border:"border-l-red-500",headerBg:"bg-red-950/30"}}function ny(t,l){if(l<=0)return"bg-green-500/70";let e=t/l;return e<.2?"bg-green-500/70":e<.6?"bg-yellow-500/70":"bg-red-500/70"}function ay(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e3?`${(t/1e3).toFixed(1)}K`:t>=100?t.toFixed(0):t>=1?t.toFixed(1):t.toFixed(2)}function uy(t){return t>=1e3?`${(t/1e3).toFixed(2)} s`:t>=1?`${t.toFixed(2)} ms`:`${(t*1e3).toFixed(0)} us`}function fe(t){return t>=1e6?`${(t/1e6).toFixed(1)}M`:t>=1e3?`${(t/1e3).toFixed(1)}K`:t.toFixed(0)}function Ua(t){return t>=100?`${t.toFixed(0)}x`:t>=10?`${t.toFixed(1)}x`:`${t.toFixed(2)}x`}function Co(t){let l=t.total_cost??0;for(let e of t.children){let n=Co(e);n>l&&(l=n)}return l}function Ho(t){let l=t.actual_time_ms??0;for(let e of t.children){let n=Ho(e);n>l&&(l=n)}return l}function De(t){let l=[];function e(n){l.push(n);for(let a of n.children)e(a)}return e(t),l}function Bo(t,l){if(!l)return null;if(t.id===l)return t;for(let e of t.children){let n=Bo(e,l);if(n)return n}return null}function Ri(t){return t.plan_rows==null||t.actual_rows==null||t.plan_rows<=0||t.actual_rows<=0?null:t.actual_rows/t.plan_rows}function Qh(t){let l=Ri(t);return l==null?null:l>=1?l:1/l}function Zh(t){let l=t.node_type.toLowerCase(),e=typeof t.extra.access_type=="string"?t.extra.access_type.toLowerCase():"";return l.includes("seq scan")||l.includes("table scan")||l.includes("full scan")||e==="all"}function Kh(t){let l=t.node_type.toLowerCase(),e=Object.values(t.extra).filter(n=>typeof n=="string").join(" ").toLowerCase();return l.includes("sort")||l.includes("filesort")||l.includes("temporary")||e.includes("using temporary")||e.includes("using filesort")}function iy(t,l){let e=De(t.root),n=l??Oi(t),a=null,u=null,i=null,c=0,f=0;for(let o of e){let d=n.byId.get(o.id),g=d?.exclusiveCost??null,p=d?.exclusiveTimeMs??null;g!=null&&(a==null||g>a.value)&&(a={nodeId:o.id,nodeType:o.node_type,relation:o.relation,value:g}),p!=null&&(u==null||p>u.value)&&(u={nodeId:o.id,nodeType:o.node_type,relation:o.relation,value:p});let r=Ri(o),E=Qh(o);r!=null&&E!=null&&(i==null||E>i.value)&&(i={nodeId:o.id,nodeType:o.node_type,relation:o.relation,value:E,ratio:r}),Zh(o)&&(c+=1),Kh(o)&&(f+=1)}return{highestCostNode:a,slowestNode:u,largestRowMismatchNode:i,sequentialScans:c,tempOperations:f}}function cy(t){switch(t.driver){case"postgres":return t.has_analyze_data?["editor.visualExplain.postgresAnalyzeLegend1","editor.visualExplain.postgresAnalyzeLegend2"]:["editor.visualExplain.postgresEstimateLegend1","editor.visualExplain.postgresEstimateLegend2"];case"mysql":return t.has_analyze_data?["editor.visualExplain.mysqlAnalyzeLegend1","editor.visualExplain.mysqlAnalyzeLegend2"]:["editor.visualExplain.mysqlEstimateLegend1","editor.visualExplain.mysqlEstimateLegend2"];case"sqlite":return["editor.visualExplain.sqliteLegend1","editor.visualExplain.sqliteLegend2"];default:return[]}}var Ui={hotspotTimeShare:.25,hotspotMinTimeMs:1,estimateWarnFactor:4,estimateCriticalFactor:10,estimateMinRows:10,filterLossRatio:.9,filterLossMinRows:1e3,seqScanMinRows:1e4,heapFetchesMinCount:1e3,highLoopsMinCount:1e3,cacheMissRatio:.5,cacheMissMinBlocks:1e3},Di={critical:0,warning:1,info:2};function qi(t,l){let e=t.extra[l];return typeof e=="string"?e:null}function Ca(t,l){let e=t.extra[l];if(typeof e=="number")return e;if(typeof e=="string"){let n=Number(e);return Number.isFinite(n)?n:null}return null}function Vh(t){let l=t.node_type.toLowerCase(),e=qi(t,"access_type")?.toLowerCase()??"";return l.includes("seq scan")||l.includes("table scan")||l.includes("full scan")||e==="all"}function Jh(t){let l=qi(t,"Sort Method")?.toLowerCase()??"",e=qi(t,"Sort Space Type")?.toLowerCase()??"";return l.includes("disk")||e==="disk"?!0:(Ca(t,"Temp Written Blocks")??0)>0}function Wh(t){let{estimateMinRows:l,estimateWarnFactor:e,estimateCriticalFactor:n}=Ui;if(t.plan_rows==null||t.actual_rows==null||t.plan_rows<=0||t.actual_rows=1?a:1/a;if(u=n?"critical":"warning";return a>=1?{kind:"over-estimate",severity:i,labelKey:"editor.visualExplain.diagnostics.overEstimate",descriptionKey:"editor.visualExplain.diagnostics.overEstimateHint",value:Ua(u)}:{kind:"under-estimate",severity:i,labelKey:"editor.visualExplain.diagnostics.underEstimate",descriptionKey:"editor.visualExplain.diagnostics.underEstimateHint",value:Ua(u)}}function wo(t,l){let e=Ui,n=[];if(l?.neverExecuted)return n.push({kind:"never-executed",severity:"info",labelKey:"editor.visualExplain.diagnostics.neverExecuted",descriptionKey:"editor.visualExplain.diagnostics.neverExecutedHint"}),n;l?.timeShare!=null&&l.timeShare>=e.hotspotTimeShare&&(l.exclusiveTimeMs??0)>=e.hotspotMinTimeMs&&n.push({kind:"hotspot",severity:"critical",labelKey:"editor.visualExplain.diagnostics.hotspot",descriptionKey:"editor.visualExplain.diagnostics.hotspotHint",value:`${Math.round(l.timeShare*100)}%`});let a=Wh(t);a&&n.push(a),Jh(t)&&n.push({kind:"disk-sort",severity:"warning",labelKey:"editor.visualExplain.diagnostics.diskSort",descriptionKey:"editor.visualExplain.diagnostics.diskSortHint",value:qi(t,"Sort Method")??void 0});let u=Ca(t,"Rows Removed by Filter");if(u!=null&&u>=e.filterLossMinRows){let d=(t.actual_rows??0)+u;d>0&&u/d>=e.filterLossRatio&&n.push({kind:"filter-loss",severity:"warning",labelKey:"editor.visualExplain.diagnostics.filterLoss",descriptionKey:"editor.visualExplain.diagnostics.filterLossHint",value:fe(u)})}if(Vh(t)){let o=t.actual_rows??t.plan_rows;o!=null&&o>=e.seqScanMinRows&&n.push({kind:"large-seq-scan",severity:"warning",labelKey:"editor.visualExplain.diagnostics.largeSeqScan",descriptionKey:"editor.visualExplain.diagnostics.largeSeqScanHint",value:fe(o)})}let i=Ca(t,"Heap Fetches");i!=null&&i>=e.heapFetchesMinCount&&n.push({kind:"heap-fetches",severity:"warning",labelKey:"editor.visualExplain.diagnostics.heapFetches",descriptionKey:"editor.visualExplain.diagnostics.heapFetchesHint",value:fe(i)});let c=Ca(t,"Workers Planned"),f=Ca(t,"Workers Launched");return c!=null&&f!=null&&f=e.highLoopsMinCount&&n.push({kind:"high-loops",severity:"info",labelKey:"editor.visualExplain.diagnostics.highLoops",descriptionKey:"editor.visualExplain.diagnostics.highLoopsHint",value:fe(t.actual_loops)}),t.buffers_read!=null&&t.buffers_read>=e.cacheMissMinBlocks&&t.buffers_read/(t.buffers_read+(t.buffers_hit??0))>=e.cacheMissRatio&&n.push({kind:"cache-miss",severity:"info",labelKey:"editor.visualExplain.diagnostics.cacheMiss",descriptionKey:"editor.visualExplain.diagnostics.cacheMissHint",value:fe(t.buffers_read)}),n.sort((o,d)=>Di[o.severity]-Di[d.severity])}function fy(t,l){let e=new Map;for(let n of De(t.root)){let a=wo(n,l.byId.get(n.id));a.length>0&&e.set(n.id,a)}return e}function oy(t){let l=null;for(let e of t)(l==null||Di[e.severity]0?l:null}function ry(t,l){let e=De(t.root),n=new Map,a=new Map,u=new Map,i=0,c=0,f=wa();for(let r of e){let E=l.byId.get(r.id),S=E?.exclusiveTimeMs??null;i=Math.max(i,(E?.depth??0)+1),E?.neverExecuted&&(c+=1),Ha(f,S);let M=n.get(r.node_type)??{count:0,time:wa()};if(M.count+=1,Ha(M.time,S),n.set(r.node_type,M),r.relation){let s=a.get(r.relation)??{accessCount:0,nodeTypes:[],time:wa(),rows:wa()};s.accessCount+=1,s.nodeTypes.includes(r.node_type)||s.nodeTypes.push(r.node_type),Ha(s.time,S),Ha(s.rows,E?.totalRows??null),a.set(r.relation,s)}let m=kh(r);if(m){let s=u.get(m)??{relation:r.relation,scanCount:0,time:wa()};s.scanCount+=1,s.relation=s.relation??r.relation,Ha(s.time,S),u.set(m,s)}}let o=Ba(f),d=Array.from(n.entries()).map(([r,E])=>{let S=Ba(E.time);return{nodeType:r,count:E.count,exclusiveTimeMs:S,timeShare:S!=null&&o!=null&&o>0?S/o:null}}).sort(Fh),g=Array.from(a.entries()).map(([r,E])=>({relation:r,accessCount:E.accessCount,nodeTypes:E.nodeTypes,exclusiveTimeMs:Ba(E.time),totalRows:Ba(E.rows)})).sort((r,E)=>(E.exclusiveTimeMs??0)-(r.exclusiveTimeMs??0)||E.accessCount-r.accessCount||r.relation.localeCompare(E.relation)),p=Array.from(u.entries()).map(([r,E])=>({indexName:r,relation:E.relation,scanCount:E.scanCount,exclusiveTimeMs:Ba(E.time)})).sort((r,E)=>(E.exclusiveTimeMs??0)-(r.exclusiveTimeMs??0)||E.scanCount-r.scanCount||r.indexName.localeCompare(E.indexName));return{nodeCount:e.length,maxDepth:i,neverExecutedCount:c,totalExclusiveTimeMs:o,nodeTypes:d,relations:g,indexes:p}}function Fh(t,l){return(l.exclusiveTimeMs??0)-(t.exclusiveTimeMs??0)||l.count-t.count||t.nodeType.localeCompare(l.nodeType)}var Ci=class{nextId=0;pending=new Map;port;handler;constructor(l,e){this.port=l,this.handler=e,l.onmessage=n=>{let a=n.data;if(!(!a||typeof a!="object")){if(a.kind==="response"&&typeof a.id=="number"){let u=this.pending.get(a.id);if(!u)return;this.pending.delete(a.id),typeof a.error=="string"?u.reject(new Error(a.error)):u.resolve(a.result);return}a.kind==="request"&&typeof a.id=="number"&&typeof a.method=="string"&&this.respond(a.id,a.method,a.params)}},l.start()}call(l,e=[]){let n=++this.nextId;return new Promise((a,u)=>{this.pending.set(n,{resolve:a,reject:u}),this.port.postMessage({kind:"request",id:n,method:l,params:e})})}close(){this.port.close();for(let l of this.pending.values())l.reject(new Error("Plugin frame closed"));this.pending.clear()}async respond(l,e,n){try{let a=await this.handler(e,n);this.port.postMessage({kind:"response",id:l,result:a})}catch(a){this.port.postMessage({kind:"response",id:l,error:a instanceof Error?a.message:String(a)})}}};var Yo=window,yy=new URLSearchParams(location.search),Lo=yy.get("module")??"",dy=yy.get("mode")??"ui",La=decodeURIComponent(location.pathname.split("/")[1]??""),el={},Bi,Hi,Ya,gy=I.createContext(null);function Ih(t,l){return{__slot:t,component:l}}var Ph=Object.freeze({defineSlot:Ih,usePluginConnection:()=>({connectionId:el.connectionId??null,driver:el.driver??null,schema:el.schema??null}),usePluginQuery:()=>{let[t,l]=I.useState(!1),[e,n]=I.useState(null);return{executeQuery:I.useCallback(async u=>{if(!Ya)throw new Error("Plugin host connection is unavailable");l(!0),n(null);try{return await Ya.call("api.query",[u])}catch(i){let c=i instanceof Error?i.message:String(i);throw n(c),new Error(c)}finally{l(!1)}},[]),loading:t,error:e}},usePluginToast:()=>({showInfo:t=>ja("api.toast","info",t),showError:t=>ja("api.toast","error",t),showWarning:t=>ja("api.toast","warning",t)}),usePluginSetting:t=>{if(t!==La)throw new Error("Plugins may only access their own settings");let l=qe(el.pluginSettings),e=(u,i)=>Object.hasOwn(l,u)?l[u]:i,n=async(u,i)=>{await ja("api.setting.set",u,i),el={...el,pluginSettings:{...qe(el.pluginSettings),[u]:i}},Bi?.()};return{getSetting:e,setSetting:n,setSettings:async u=>{for(let[i,c]of Object.entries(u))await n(i,c)}}},usePluginTranslation:t=>{if(t!==La)throw new Error("Plugins may only access their own translations");let l=qe(el.translations);return(e,n)=>{let a=l;for(let i of e.split("."))a=qe(a)[i];let u=typeof a=="string"?a:e;for(let[i,c]of Object.entries(n??{}))u=u.replaceAll(`{{${i}}}`,String(c));return u}},usePluginModal:()=>{let t=I.useContext(gy);if(!t)throw new Error("Plugin modal API must be used inside a plugin component");return t},usePluginTheme:()=>{let t=qe(el.theme);return{themeId:typeof t.themeId=="string"?t.themeId:null,themeName:typeof t.themeName=="string"?t.themeName:null,isDark:t.isDark===!0,colors:Object.keys(qe(t.colors)).length>0?qe(t.colors):null}},openUrl:t=>ja("api.openUrl",t)});function qe(t){return typeof t=="object"&&t!==null&&!Array.isArray(t)?t:{}}function ja(t,...l){return Ya?Ya.call(t,l).then(()=>{}):Promise.reject(new Error("Plugin host connection is unavailable"))}function t1({component:t}){let[l,e]=I.useState(null),n=I.useMemo(()=>({openModal:i=>e(i),closeModal:()=>e(null)}),[]),a={position:"fixed",inset:0,zIndex:2147483647,display:"grid",placeItems:"center",padding:16,background:"rgba(0,0,0,.6)"},u={width:l?.size==="sm"?"min(100%, 500px)":l?.size==="md"?"min(100%, 700px)":l?.size==="xl"?"min(100%, 1100px)":"min(100%, 900px)",maxHeight:"90%",overflow:"auto",background:"#111827",color:"#f9fafb",border:"1px solid #374151",borderRadius:12,padding:16};return I.createElement(gy.Provider,{value:n},I.createElement(t,{context:el,pluginId:La}),l&&I.createElement("div",{style:a,onMouseDown:i=>{i.target===i.currentTarget&&e(null)}},I.createElement("section",{role:"dialog","aria-modal":!0,"aria-label":l.title,style:u,onKeyDown:i=>{i.key==="Escape"&&e(null)},tabIndex:-1},I.createElement("header",{style:{display:"flex",justifyContent:"space-between",alignItems:"center",marginBottom:12}},I.createElement("h2",null,l.title),I.createElement("button",{type:"button",onClick:()=>e(null),"aria-label":"Close dialog"},"\xD7")),l.content)))}Object.assign(Yo,{React:I,ReactJSXRuntime:$h,__TABULARIS_EXPLAIN__:jo,__TABULARIS_API__:Ph,__TABULARIS_API_VERSION__:"0.2.0"});function my(){return Hi||(Hi=new Promise((t,l)=>{let e=document.createElement("script");e.src=`/${encodeURIComponent(La)}/${Lo.replace(/^\.\//,"").replace(/^\//,"")}`,e.onload=()=>{let n=Yo.__tabularis_explain_parser__,a=n&&typeof n=="object"&&Object.prototype.hasOwnProperty.call(n,"default")?n.default:n,i=(a==null?[]:Array.isArray(a)?a:[a]).filter(c=>!!c&&typeof c=="object"&&typeof c.engine=="string"&&typeof c.format=="string"&&typeof c.parse=="function");t(i)},e.onerror=()=>l(new Error("Plugin parser bundle failed to load")),document.head.append(e)}),Hi)}function l1(t){let l=new Ci(t,async(n,a)=>{if(dy==="explain"){let u=Array.isArray(a)?a:[a];if(n==="explain.load"){if(u[0]!==Lo)throw new Error("module_mismatch");return(await my()).map(c=>({engine:c.engine,format:c.format,...typeof c.label=="string"?{label:c.label}:{},hasSniff:typeof c.sniff=="function"}))}if(n==="explain.parse"||n==="explain.sniff"){let[i,c]=u;if(typeof i!="string"||typeof c!="string")throw new Error("invalid_parser_arguments");let f=(await my()).find(o=>o.format===i);if(!f)throw new Error("parser_not_found");return n==="explain.parse"?f.parse(c):typeof f.sniff!="function"?!1:f.sniff(c)}throw new Error("method_not_allowed")}if(n==="slot.context"){let u=Array.isArray(a)?a[0]:a;return u&&typeof u=="object"&&!Array.isArray(u)&&(el=u,Bi?.()),null}throw new Error("method_not_allowed")});if(Ya=l,dy==="explain")return;let e=document.createElement("script");e.src=`/${encodeURIComponent(La)}/${Lo.replace(/^\.\//,"").replace(/^\//,"")}`,e.onload=()=>{let n=Yo.__tabularis_plugin__,a=typeof n=="function"?n:n&&typeof n=="object"?n.default:null;if(typeof a!="function"){l.call("host.loadFailed",["Plugin bundle has no default component export"]).catch(()=>{});return}let u=document.getElementById("root");if(!u)return;let i=(0,py.createRoot)(u);Bi=()=>i.render(I.createElement(t1,{component:a})),Bi(),new ResizeObserver(()=>{l.call("ui.resize",[document.documentElement.scrollHeight]).catch(()=>{})}).observe(document.body)},e.onerror=()=>{l.call("host.loadFailed",["Plugin bundle failed to load"]).catch(()=>{})},document.head.append(e)}var hy=()=>window.parent.postMessage({type:"tabularis:ready"},"*"),e1=window.setInterval(hy,100);hy();window.addEventListener("message",function t(l){if(l.source!==window.parent||l.data?.type!=="tabularis:init")return;let e=l.ports[0];e&&(window.clearInterval(e1),window.removeEventListener("message",t),l1(e))});})(); diff --git a/src-tauri/src/backup/mod.rs b/src-tauri/src/backup/mod.rs index 4c0346a90..bb76b6de5 100644 --- a/src-tauri/src/backup/mod.rs +++ b/src-tauri/src/backup/mod.rs @@ -336,7 +336,8 @@ pub async fn run_backup(app: AppHandle, trigger: &str) -> Result< let config = crate::config::load_config_internal(&app); let password = get_password()?.ok_or("Backup password is not set")?; - let payload = crate::commands::export_connections_payload(app.clone(), Some(true), None).await?; + let payload = + crate::commands::export_connections_payload_unconfirmed(app.clone(), true, None).await?; let plaintext = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let envelope = crate::export_crypto::encrypt(&plaintext, &password)?; let content = serde_json::to_string_pretty(&envelope).map_err(|e| e.to_string())?; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b1e963fba..7ece1a82a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6533,8 +6533,54 @@ pub async fn export_connections_payload( app: AppHandle, include_secrets: Option, connection_ids: Option>, + confirmation_title: Option, + confirmation_message: Option, + confirmation_export_label: Option, + confirmation_cancel_label: Option, +) -> Result { + // Design note Phase 1: default false so no-arg / plugin callers get no secrets. + let include_secrets = include_secrets.unwrap_or(false); + + if include_secrets { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + + let app2 = app.clone(); + let title = confirmation_title.unwrap_or_else(|| "Export secrets".into()); + let message = confirmation_message.unwrap_or_else(|| { + "Export saved passwords, SSH keys and connection URIs in plain form?".into() + }); + let export_label = confirmation_export_label.unwrap_or_else(|| "Export".into()); + let cancel_label = confirmation_cancel_label.unwrap_or_else(|| "Cancel".into()); + let ok = tauri::async_runtime::spawn_blocking(move || { + app2.dialog() + .message(message) + .title(title) + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + export_label, + cancel_label, + )) + .blocking_show() + }) + .await + .map_err(|e| e.to_string())?; + + if !ok { + return Err("SECRET_EXPORT_CANCELLED".into()); + } + } + + export_connections_payload_unconfirmed(app, include_secrets, connection_ids).await +} + +/// Build the payload for trusted internal callers such as encrypted backups. +/// The Tauri command above is the only UI-facing path for secret exports and +/// always shows the native confirmation before calling this helper. +pub(crate) async fn export_connections_payload_unconfirmed( + app: AppHandle, + include_secrets: bool, + connection_ids: Option>, ) -> Result { - let include_secrets = include_secrets.unwrap_or(true); let conn_path = get_config_path(&app)?; let ssh_path = get_ssh_config_path(&app)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dfe576b83..532d5e31e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -255,6 +255,9 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_deep_link::init()) + .register_uri_scheme_protocol("tabularis-plugin", |ctx, request| { + crate::plugins::sandbox_protocol::handle(ctx.app_handle(), request) + }) .manage(crate::plugins::deep_link::PendingInstall::default()) .manage(commands::QueryCancellationState::default()) .manage(export::ExportCancellationState::default()) diff --git a/src-tauri/src/plugins/commands.rs b/src-tauri/src/plugins/commands.rs index 8cdc61a76..72bcf2cce 100644 --- a/src-tauri/src/plugins/commands.rs +++ b/src-tauri/src/plugins/commands.rs @@ -1,5 +1,3 @@ -use std::fs; - use crate::drivers::driver_trait::PluginManifest; use crate::plugins::installer::{self, InstalledPluginInfo}; use crate::plugins::manager::ConfigManifest; @@ -461,13 +459,8 @@ pub async fn fetch_plugin_readme( /// The `file_path` must be a relative path with no `..` components. #[tauri::command] pub fn read_plugin_file(plugin_id: String, file_path: String) -> Result { - if file_path.contains("..") || file_path.starts_with('/') || file_path.starts_with('\\') { - return Err( - "Invalid file path: must be relative and contain no '..' components".to_string(), - ); - } - let full_path = installer::resolve_plugin_dir(&plugin_id)?.join(&file_path); - fs::read_to_string(&full_path).map_err(|e| { + let bytes = crate::plugins::sandbox_protocol::read_plugin_file_confined(&plugin_id, &file_path)?; + String::from_utf8(bytes).map_err(|e| { format!( "Failed to read '{}' from plugin '{}': {}", file_path, plugin_id, e diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index db186f046..1fe1f4c0f 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod package_kind; pub mod registry; mod registry_kind; pub mod rpc; +pub mod sandbox_protocol; pub mod runtime_version; pub mod tabularium; diff --git a/src-tauri/src/plugins/sandbox_protocol.rs b/src-tauri/src/plugins/sandbox_protocol.rs new file mode 100644 index 000000000..12553094b --- /dev/null +++ b/src-tauri/src/plugins/sandbox_protocol.rs @@ -0,0 +1,134 @@ +//! Custom URI scheme `tabularis-plugin://` for sandboxed plugin frames. +//! Design note section 8.2 — protocol handler + confined file reader. + +use std::fs; +use std::path::{Component, Path}; + +use tauri::http::{header, Request, Response, StatusCode}; +use tauri::{AppHandle, Runtime}; + +const GUEST_CSP: &str = "sandbox allow-scripts; default-src 'none'; script-src 'self'; \ + style-src 'self' 'unsafe-inline'; img-src data: blob:; font-src data:; \ + connect-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'"; + +static GUEST_HTML: &[u8] = include_bytes!("../../resources/guest/__guest__.html"); +static GUEST_RUNTIME_JS: &[u8] = include_bytes!("../../resources/guest/runtime.js"); +static HOST_CSS: &[u8] = include_bytes!("../../resources/guest/host.css"); + +fn is_safe_plugin_id(id: &str) -> bool { + !id.is_empty() + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) +} + +fn mime_for(path: &str) -> &'static str { + if path.ends_with(".js") { + "application/javascript; charset=utf-8" + } else if path.ends_with(".css") { + "text/css; charset=utf-8" + } else if path.ends_with(".json") { + "application/json; charset=utf-8" + } else if path.ends_with(".html") { + "text/html; charset=utf-8" + } else { + "application/octet-stream" + } +} + +fn plain(status: StatusCode, msg: &str) -> Response> { + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "text/plain; charset=utf-8") + .header(header::CONTENT_SECURITY_POLICY, GUEST_CSP) + .body(msg.as_bytes().to_vec()) + .unwrap() +} + +/// Path-safe read shared by the protocol handler and `read_plugin_file`. +pub fn read_plugin_file_confined(plugin_id: &str, rel: &str) -> Result, String> { + if rel.is_empty() || rel.contains('\0') { + return Err("Invalid file path".into()); + } + if Path::new(rel).components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err( + "Invalid file path: must be relative and contain no '..' components".into(), + ); + } + + let plugin_dir = crate::plugins::installer::resolve_plugin_dir(plugin_id)?; + let plugin_root = plugin_dir + .canonicalize() + .map_err(|e| format!("Plugin dir not found for '{plugin_id}': {e}"))?; + + let full = plugin_root.join(rel); + let canonical = full + .canonicalize() + .map_err(|e| format!("Failed to resolve '{rel}' in plugin '{plugin_id}': {e}"))?; + + if !canonical.starts_with(&plugin_root) { + return Err("Path escapes plugin directory".into()); + } + + fs::read(&canonical).map_err(|e| { + format!("Failed to read '{rel}' from plugin '{plugin_id}': {e}") + }) +} + +pub fn handle(_app: &AppHandle, req: Request>) -> Response> { + let path = req.uri().path().trim_start_matches('/'); + let mut parts = path.splitn(2, '/'); + let plugin_id = parts.next().unwrap_or(""); + let rel = parts.next().unwrap_or("__guest__.html"); + + if !is_safe_plugin_id(plugin_id) { + return plain(StatusCode::BAD_REQUEST, "invalid plugin id"); + } + + if crate::plugins::installer::resolve_plugin_dir(plugin_id).is_err() { + return plain(StatusCode::NOT_FOUND, "plugin not installed"); + } + + let (body, content_type) = match rel { + "__guest__.html" => (GUEST_HTML.to_vec(), "text/html; charset=utf-8"), + "runtime.js" | "__runtime__.js" => { + (GUEST_RUNTIME_JS.to_vec(), "application/javascript; charset=utf-8") + } + "host.css" | "__host.css__" => (HOST_CSS.to_vec(), "text/css; charset=utf-8"), + other => match read_plugin_file_confined(plugin_id, other) { + Ok(bytes) => (bytes, mime_for(other)), + Err(_) => return plain(StatusCode::NOT_FOUND, "not found"), + }, + }; + + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_SECURITY_POLICY, GUEST_CSP) + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header(header::CACHE_CONTROL, "no-store") + .body(body) + .unwrap_or_else(|_| plain(StatusCode::INTERNAL_SERVER_ERROR, "response build failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_parent_dir() { + let err = read_plugin_file_confined("any", "../etc/passwd").unwrap_err(); + assert!(err.contains("Invalid file path")); + } + + #[test] + fn rejects_absolute() { + let err = read_plugin_file_confined("any", "/etc/passwd").unwrap_err(); + assert!(err.contains("Invalid file path")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 19df67132..6b234ec0e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -6,8 +6,8 @@ "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", - "beforeDevCommand": "npm run dev", - "beforeBuildCommand": "npm run build" + "beforeDevCommand": "pnpm build:plugin-guest && pnpm dev", + "beforeBuildCommand": "pnpm build:plugin-guest && pnpm build" }, "app": { "windows": [ @@ -22,7 +22,19 @@ } ], "security": { - "csp": null, + "csp": { + "default-src": "'self'", + "script-src": "'self'", + "worker-src": "'self' blob:", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' data: blob: asset: http://asset.localhost", + "font-src": "'self' data:", + "connect-src": "ipc: http://ipc.localhost https://raw.githubusercontent.com", + "frame-src": "tabularis-plugin: http://tabularis-plugin.localhost", + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'" + }, "assetProtocol": { "enable": true, "scope": [ @@ -76,4 +88,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/components/modals/AiApprovalModal.tsx b/src/components/modals/AiApprovalModal.tsx index a765ec13b..317a79d49 100644 --- a/src/components/modals/AiApprovalModal.tsx +++ b/src/components/modals/AiApprovalModal.tsx @@ -77,10 +77,16 @@ export function AiApprovalModal({ return () => window.removeEventListener("keydown", handler, true); }, [planExpanded]); - const explainPlan = useMemo( - () => parseApprovalExplainPlan(approval.explainPlan), - [approval.explainPlan], - ); + const [explainPlan, setExplainPlan] = useState(null); + useEffect(() => { + let cancelled = false; + void parseApprovalExplainPlan(approval.explainPlan).then((plan) => { + if (!cancelled) setExplainPlan(plan); + }); + return () => { + cancelled = true; + }; + }, [approval.explainPlan]); const planSummary = useMemo( () => (explainPlan ? getExplainPlanSummary(explainPlan) : null), diff --git a/src/contexts/PluginSlotProvider.tsx b/src/contexts/PluginSlotProvider.tsx index d963387b6..a55fbf411 100644 --- a/src/contexts/PluginSlotProvider.tsx +++ b/src/contexts/PluginSlotProvider.tsx @@ -1,8 +1,6 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import * as React from "react"; -import * as ReactJSXRuntime from "react/jsx-runtime"; +import type { ReactNode } from "react"; import { invoke } from "@tauri-apps/api/core"; -import * as explainApi from "@tabularis/explain"; import i18n from "i18next"; import { PluginSlotContext } from "./PluginSlotContext"; @@ -10,33 +8,15 @@ import type { PluginSlotRegistryType } from "./PluginSlotContext"; import type { SlotContribution, SlotName, SlotContext } from "../types/pluginSlots"; import { VALID_SLOTS } from "../types/pluginSlots"; import type { PluginManifest } from "../types/plugins"; -import * as pluginApi from "../pluginApi"; +import { SandboxedPluginFrame } from "../plugins/sandbox/SandboxedPluginFrame"; import { useSettings } from "../hooks/useSettings"; -import { loadPluginExplainParsers } from "../utils/pluginExplainLoader"; +import { + disposePluginExplainParsers, + loadPluginExplainParsers, +} from "../utils/pluginExplainLoader"; interface PluginSlotProviderProps { - children: React.ReactNode; -} - -/** - * Expose host globals so external plugin bundles (IIFE format) can access - * React and the Tabularis plugin API without bundling their own copies. - */ -/** - * Keep in sync with `packages/plugin-api/src/version.ts` (API_VERSION). - * Bump when the host API shape changes in a way plugin bundles can observe. - */ -const HOST_API_VERSION = "0.1.1"; - -let globalsExposed = false; -function exposePluginGlobals() { - if (globalsExposed) return; - globalsExposed = true; - (window as unknown as Record).React = React; - (window as unknown as Record).ReactJSXRuntime = ReactJSXRuntime; - (window as unknown as Record).__TABULARIS_API__ = pluginApi; - (window as unknown as Record).__TABULARIS_API_VERSION__ = HOST_API_VERSION; - (window as unknown as Record).__TABULARIS_EXPLAIN__ = explainApi; + children: ReactNode; } /** @@ -62,23 +42,12 @@ async function loadPluginTranslations(pluginId: string): Promise { } } -/** - * Load all UI extension contributions from a plugin's pre-built IIFE bundle. - * - * The bundle is expected to be built with: - * - format: 'iife', name: '__tabularis_plugin__' - * - externals: { react, react/jsx-runtime, @tabularis/plugin-api } - * - globals: { react: 'React', 'react/jsx-runtime': 'ReactJSXRuntime', - * '@tabularis/plugin-api': '__TABULARIS_API__' } - */ async function loadExternalPluginContributions( manifest: PluginManifest, ): Promise { if (!manifest.ui_extensions?.length) return []; - - exposePluginGlobals(); - - // Group entries by module path to load each bundle once. + // Create one iframe-backed component per module. Plugin source is loaded by + // the isolated guest page and is never evaluated in the main app window. const byModule = new Map(); for (const entry of manifest.ui_extensions) { const list = byModule.get(entry.module) ?? []; @@ -87,53 +56,19 @@ async function loadExternalPluginContributions( } const contributions: SlotContribution[] = []; - for (const [modulePath, entries] of byModule) { - try { - const source = await invoke("read_plugin_file", { + const component: SlotContribution["component"] = ({ context, pluginId }) => ( + + ); + for (const entry of entries) { + if (!VALID_SLOTS.has(entry.slot)) continue; + contributions.push({ pluginId: manifest.id, - filePath: modulePath, + slot: entry.slot as SlotName, + component, + order: entry.order, + when: entry.driver ? (ctx: SlotContext) => ctx.driver === entry.driver : undefined, }); - - // Execute the IIFE, passing React globals as parameters. - // The bundle assigns its exports to the local `__tabularis_plugin__` var. - const fn = new Function( - "React", - "ReactJSXRuntime", - "__TABULARIS_API__", - source + "\n return typeof __tabularis_plugin__ !== 'undefined' ? __tabularis_plugin__ : null;", - ); - const raw = fn(React, ReactJSXRuntime, pluginApi) as unknown; - // Support both direct export (`return Component`) and ES-module style (`return { default: Component }`). - const component = - typeof raw === "function" - ? raw - : (raw as { default?: unknown } | null)?.default ?? null; - - if (!component || typeof component !== "function") { - console.warn( - `[PluginSlot] Plugin "${manifest.id}" module "${modulePath}" has no default component export. Skipping.`, - ); - continue; - } - - for (const entry of entries) { - if (!VALID_SLOTS.has(entry.slot)) { - continue; - } - contributions.push({ - pluginId: manifest.id, - slot: entry.slot as SlotName, - component: component as React.ComponentType<{ context: SlotContext; pluginId: string }>, - order: entry.order, - when: entry.driver ? (ctx: SlotContext) => ctx.driver === entry.driver : undefined, - }); - } - } catch (err) { - console.error( - `[PluginSlot] Failed to load module "${modulePath}" for plugin "${manifest.id}":`, - err, - ); } } @@ -172,7 +107,7 @@ export const PluginSlotProvider = ({ children }: PluginSlotProviderProps) => { const loadedExplainFormats: string[] = []; for (const format of new Set(loadedExplainFormatsRef.current)) { - explainApi.unregisterExplainParser(format); + disposePluginExplainParsers([format]); } loadedExplainFormatsRef.current = []; @@ -185,12 +120,11 @@ export const PluginSlotProvider = ({ children }: PluginSlotProviderProps) => { if (cancelled) break; try { const manifest = await invoke("get_plugin_manifest", { pluginId }); - exposePluginGlobals(); await loadPluginTranslations(pluginId); const parserFormats = await loadPluginExplainParsers(manifest, () => !cancelled); if (cancelled) { for (const format of new Set(parserFormats)) { - explainApi.unregisterExplainParser(format); + disposePluginExplainParsers([format]); } break; } @@ -212,7 +146,7 @@ export const PluginSlotProvider = ({ children }: PluginSlotProviderProps) => { return () => { cancelled = true; for (const format of new Set(loadedExplainFormats)) { - explainApi.unregisterExplainParser(format); + disposePluginExplainParsers([format]); } if (loadedExplainFormatsRef.current === loadedExplainFormats) { loadedExplainFormatsRef.current = []; diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 8495aba11..5ce4c3acf 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -351,6 +351,12 @@ "import": "Verbindungen importieren", "exportTitle": "Verbindungen exportieren", "exportWarning": "Die exportierte Datei enthält deine Datenbank- und SSH-Passwörter im Klartext. Bewahre sie sicher auf.", + "exportSecretsConfirmation": { + "title": "Export sensibler Daten bestätigen", + "message": "Gespeicherte Passwörter, SSH-Schlüssel und Verbindungs-URIs im Klartext exportieren?", + "export": "Exportieren", + "cancel": "Abbrechen" + }, "exportModal": { "title": "Verbindungen exportieren", "subtitle": "Wählen Sie, wie Ihre Verbindungen exportiert werden sollen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 131c153b7..6650f29a8 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -364,6 +364,12 @@ "import": "Import Connections", "exportTitle": "Export Connections", "exportWarning": "The exported file will contain your database and SSH passwords in plaintext. Please store it securely.", + "exportSecretsConfirmation": { + "title": "Confirm secret export", + "message": "Export saved passwords, SSH keys and connection URIs as plain text?", + "export": "Export", + "cancel": "Cancel" + }, "exportModal": { "title": "Export Connections", "subtitle": "Choose how to export your connections", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c5ec90743..5c0405009 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -371,6 +371,12 @@ "import": "Importar conexiones", "exportTitle": "Exportar conexiones", "exportWarning": "El archivo exportado contendrá tus contraseñas de base de datos y SSH en texto plano. Guárdalo de forma segura.", + "exportSecretsConfirmation": { + "title": "Confirmar exportación de secretos", + "message": "¿Exportar las contraseñas guardadas, las claves SSH y las URI de conexión en texto sin formato?", + "export": "Exportar", + "cancel": "Cancelar" + }, "exportModal": { "title": "Exportar conexiones", "subtitle": "Elige cómo exportar tus conexiones", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 701835dcb..f2e609109 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -364,6 +364,12 @@ "import": "Importer les connexions", "exportTitle": "Exporter les connexions", "exportWarning": "Le fichier exporté contiendra vos mots de passe de base de données et SSH en clair. Conservez-le en lieu sûr.", + "exportSecretsConfirmation": { + "title": "Confirmer l’exportation des secrets", + "message": "Exporter en texte brut les mots de passe enregistrés, les clés SSH et les URI de connexion ?", + "export": "Exporter", + "cancel": "Annuler" + }, "exportModal": { "title": "Exporter les connexions", "subtitle": "Choisissez comment exporter vos connexions", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 92d0657f8..9fe447424 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -378,6 +378,12 @@ "import": "Importa connessioni", "exportTitle": "Esporta connessioni", "exportWarning": "Il file esportato conterrà le password del database e SSH in chiaro. Conservalo in modo sicuro.", + "exportSecretsConfirmation": { + "title": "Conferma esportazione dei segreti", + "message": "Esportare in testo semplice le password salvate, le chiavi SSH e gli URI di connessione?", + "export": "Esporta", + "cancel": "Annulla" + }, "exportModal": { "title": "Esporta connessioni", "subtitle": "Scegli come esportare le tue connessioni", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4cde6bbc8..cd9eae11f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -364,6 +364,12 @@ "import": "接続をインポート", "exportTitle": "接続をエクスポート", "exportWarning": "エクスポートされたファイルには、データベースおよび SSH のパスワードが平文で含まれます。安全な場所に保管してください。", + "exportSecretsConfirmation": { + "title": "シークレットのエクスポートを確認", + "message": "保存済みのパスワード、SSH キー、接続 URI を平文でエクスポートしますか?", + "export": "エクスポート", + "cancel": "キャンセル" + }, "exportModal": { "title": "接続をエクスポート", "subtitle": "接続のエクスポート方法を選択してください", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index d720ee911..77ee032db 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -334,6 +334,12 @@ "import": "연결 가져오기", "exportTitle": "연결 내보내기", "exportWarning": "내보낸 파일에는 데이터베이스 및 SSH 비밀번호가 평문으로 포함됩니다. 안전하게 보관하세요.", + "exportSecretsConfirmation": { + "title": "비밀 정보 내보내기 확인", + "message": "저장된 비밀번호, SSH 키 및 연결 URI를 일반 텍스트로 내보낼까요?", + "export": "내보내기", + "cancel": "취소" + }, "importFromApp": { "title": "앱에서 가져오기", "subtitle": "설치된 다른 SQL 클라이언트에서 연결을 가져옵니다", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 087ae0f2c..65bf702e5 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -335,6 +335,12 @@ "import": "Importar Conexões", "exportTitle": "Exportar Conexões", "exportWarning": "O arquivo exportado conterá suas senhas de banco de dados e SSH em texto simples. Guarde-o com segurança.", + "exportSecretsConfirmation": { + "title": "Confirmar exportação de segredos", + "message": "Exportar senhas salvas, chaves SSH e URIs de conexão em texto simples?", + "export": "Exportar", + "cancel": "Cancelar" + }, "exportModal": { "title": "Exportar Conexões", "subtitle": "Escolha como exportar suas conexões", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index b0bbe303a..f2094f2f2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -367,6 +367,12 @@ "import": "Импортировать подключения", "exportTitle": "Экспорт подключений", "exportWarning": "Экспортируемый файл будет содержать пароли к базам данных и SSH в открытом виде. Храните его в надёжном месте!", + "exportSecretsConfirmation": { + "title": "Подтвердите экспорт секретов", + "message": "Экспортировать сохранённые пароли, SSH-ключи и URI подключений в открытом виде?", + "export": "Экспортировать", + "cancel": "Отмена" + }, "exportModal": { "title": "Экспорт подключений", "subtitle": "Выберите способ экспорта подключений", diff --git a/src/i18n/locales/tl.json b/src/i18n/locales/tl.json index fbda378a6..1d9fe3d85 100644 --- a/src/i18n/locales/tl.json +++ b/src/i18n/locales/tl.json @@ -364,6 +364,12 @@ "import": "I-import ang mga Koneksyon", "exportTitle": "I-export ang mga Koneksyon", "exportWarning": "Ang na-export na file ay maglalaman ng iyong database at SSH passwords sa plaintext. Pakitago ito nang ligtas.", + "exportSecretsConfirmation": { + "title": "Kumpirmahin ang pag-export ng mga lihim", + "message": "I-export bilang plain text ang mga naka-save na password, SSH key, at connection URI?", + "export": "I-export", + "cancel": "Kanselahin" + }, "exportModal": { "title": "I-export ang mga Koneksyon", "subtitle": "Piliin kung paano i-export ang iyong mga koneksyon", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 695d5a84f..a9a7c3025 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -350,6 +350,12 @@ "import": "导入连接", "exportTitle": "导出连接", "exportWarning": "导出的文件将包含您的数据库和 SSH 明文密码。请妥善保管该文件。", + "exportSecretsConfirmation": { + "title": "确认导出机密信息", + "message": "要以明文导出已保存的密码、SSH 密钥和连接 URI 吗?", + "export": "导出", + "cancel": "取消" + }, "exportModal": { "title": "导出连接", "subtitle": "选择导出连接的方式", diff --git a/src/pages/Connections.tsx b/src/pages/Connections.tsx index ec8201065..ff18c5933 100644 --- a/src/pages/Connections.tsx +++ b/src/pages/Connections.tsx @@ -484,6 +484,10 @@ export const Connections = () => { try { const payload = await invoke("export_connections_payload", { includeSecrets: mode !== "noSecrets", + confirmationTitle: t("connections.exportSecretsConfirmation.title"), + confirmationMessage: t("connections.exportSecretsConfirmation.message"), + confirmationExportLabel: t("connections.exportSecretsConfirmation.export"), + confirmationCancelLabel: t("connections.exportSecretsConfirmation.cancel"), connectionIds: exportSelectionOnly && selectedIds.size > 0 ? [...selectedIds] @@ -501,6 +505,7 @@ export const Connections = () => { await writeTextFile(path, JSON.stringify(fileContent, null, 2)); } } catch (e) { + if (String(e).includes("SECRET_EXPORT_CANCELLED")) return; console.error("Export failed:", e); setError(toErrorMessage(e)); } diff --git a/src/pages/VisualExplainPage.tsx b/src/pages/VisualExplainPage.tsx index 5581b4ada..9ad843eb6 100644 --- a/src/pages/VisualExplainPage.tsx +++ b/src/pages/VisualExplainPage.tsx @@ -56,7 +56,7 @@ export const VisualExplainPage = ({ "load_explain_from_file", { path }, ); - return withSourceLabel(parseExplain(file.content), file.display_name); + return withSourceLabel(await parseExplain(file.content), file.display_name); }), [loadPlan], ); diff --git a/src/plugins/sandbox/SandboxedPluginFrame.tsx b/src/plugins/sandbox/SandboxedPluginFrame.tsx new file mode 100644 index 000000000..5c9899316 --- /dev/null +++ b/src/plugins/sandbox/SandboxedPluginFrame.tsx @@ -0,0 +1,158 @@ +import { useContext, useEffect, useMemo, useRef, useState } from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { message } from "@tauri-apps/plugin-dialog"; +import { openUrl as openExternal } from "@tauri-apps/plugin-opener"; +import { useTranslation } from "react-i18next"; +import { useSettings } from "../../hooks/useSettings"; +import { ThemeContext } from "../../contexts/ThemeContext"; +import { isReadOnlyQuery } from "../../utils/sqlAnalysis"; +import type { SlotContext } from "../../types/pluginSlots"; +import { connectFrame, pluginFrameUrl } from "./connectFrame"; +import type { RpcPort } from "./rpc"; + +interface Props { + pluginId: string; + module: string; + context: SlotContext; +} + +const MAX_HEIGHT = 4000; + +export function SandboxedPluginFrame({ pluginId, module, context }: Props) { + const { settings, updateSetting } = useSettings(); + const { i18n } = useTranslation(); + const language = i18n.language; + const theme = useContext(ThemeContext); + const frameRef = useRef(null); + const rpcRef = useRef(null); + const guestContext: SlotContext = useMemo( + () => ({ + ...context, + pluginSettings: settings.plugins?.[pluginId]?.settings ?? {}, + translations: + i18n.getResourceBundle(language?.split("-")[0] ?? language, pluginId) ?? {}, + theme: theme + ? { + themeId: theme.currentTheme.id, + themeName: theme.currentTheme.name, + isDark: !theme.currentTheme.id.includes("-light"), + colors: theme.currentTheme.colors, + } + : null, + }), + [context, i18n, language, pluginId, settings.plugins, theme], + ); + const contextRef = useRef(guestContext); + const [height, setHeight] = useState(0); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const controller = new AbortController(); + let disposed = false; + const handlers = { + "ui.resize": (value: unknown) => { + const requested = typeof value === "number" ? value : 0; + setHeight(Math.min(MAX_HEIGHT, Math.max(0, requested))); + }, + "slot.context": () => structuredClone(contextRef.current), + "api.query": async (query: unknown) => { + if (typeof query !== "string" || query.trim().length === 0 || query.length > 100_000) { + throw new Error("SQL query must contain between 1 and 100,000 characters"); + } + if (!isReadOnlyQuery(query)) { + throw new Error("Plugin queries must be read-only"); + } + const active = contextRef.current; + if (typeof active.connectionId !== "string" || !active.connectionId) { + throw new Error("No active connection"); + } + return invoke<{ columns: string[]; rows: unknown[][] }>("execute_query", { + connectionId: active.connectionId, + query, + ...(typeof active.schema === "string" && active.schema + ? { schema: active.schema } + : {}), + }); + }, + "api.toast": async (kind: unknown, text: unknown) => { + if (typeof text !== "string") throw new Error("Notification text must be a string"); + const dialogKind = kind === "error" || kind === "warning" ? kind : "info"; + await message(text, { kind: dialogKind }); + }, + "api.setting.set": async (key: unknown, value: unknown) => { + if (typeof key !== "string" || !key) throw new Error("Setting key is required"); + const serialized = JSON.stringify(value); + if (typeof serialized !== "string" || serialized.length > 65_536) { + throw new Error("Plugin settings must be JSON values smaller than 64 KiB"); + } + const safeValue = JSON.parse(serialized) as unknown; + await updateSetting("plugins", (previous) => { + const plugins = previous ?? {}; + const pluginConfig = plugins[pluginId] ?? {}; + return { + ...plugins, + [pluginId]: { + ...pluginConfig, + settings: { ...(pluginConfig.settings ?? {}), [key]: safeValue }, + }, + }; + }); + return null; + }, + "api.openUrl": async (value: unknown) => { + if (typeof value !== "string") throw new Error("URL must be a string"); + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("Only HTTP and HTTPS URLs can be opened"); + } + if (url.username || url.password) throw new Error("URLs containing credentials cannot be opened"); + await openExternal(url.toString()); + return null; + }, + "host.loadFailed": (message: unknown) => { + console.error(`[PluginSlot] ${pluginId}/${module}: ${String(message)}`); + }, + }; + + void connectFrame(frame, handlers, controller.signal).then(async (rpc) => { + if (disposed) { + rpc.close(); + return; + } + rpcRef.current = rpc; + try { + await rpc.call("slot.context", [contextRef.current]); + } catch (error) { + console.error(`[PluginSlot] Failed to initialize ${pluginId}/${module}`, error); + } + }).catch((error: unknown) => { + if (!controller.signal.aborted) { + console.error(`[PluginSlot] Failed to connect ${pluginId}/${module}`, error); + } + }); + + return () => { + disposed = true; + controller.abort(); + rpcRef.current?.close(); + rpcRef.current = null; + }; + }, [module, pluginId, updateSetting]); + + useEffect(() => { + contextRef.current = guestContext; + void rpcRef.current?.call("slot.context", [guestContext]).catch(() => undefined); + }, [guestContext]); + + return ( +