diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee88609f9..3944b14af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ on: - 'src-tauri/**' - 'tests/**' - 'packages/explain/**' # imported from source by the host + - 'packages/plugin-guest/**' # bundled into the Rust-embedded guest runtime - 'package.json' - 'pnpm-lock.yaml' - 'eslint.config.js' @@ -28,6 +29,7 @@ on: - 'src-tauri/**' - 'tests/**' - 'packages/explain/**' # imported from source by the host + - 'packages/plugin-guest/**' # bundled into the Rust-embedded guest runtime - 'package.json' - 'pnpm-lock.yaml' - 'eslint.config.js' @@ -62,6 +64,11 @@ jobs: - name: Install dependencies run: pnpm install + - name: Check embedded plugin guest runtime is up to date + run: | + pnpm run build:plugin-guest + git diff --exit-code -- src-tauri/resources/guest/runtime.js + - name: Typecheck run: pnpm run typecheck diff --git a/AGENTS.md b/AGENTS.md index 86936d1fe..36b06b305 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ Shared logic lives in `src-tauri/src/application/` (and plain modules), so the a # GitNexus — Code Intelligence -This project is indexed by GitNexus as **tabularis** (10537 symbols, 25156 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **tabularis** (24869 symbols, 59001 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). diff --git a/package.json b/package.json index d0acb01a9..57026c14a 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dev": "pnpm --filter @tabularis/web-ui dev", "dev:web": "node scripts/dev-web.mjs", "build": "pnpm --filter @tabularis/web-ui build", + "build:plugin-guest": "node packages/plugin-guest/build.mjs", "lint": "pnpm --filter @tabularis/web-ui lint", "lint:theme": "node scripts/check-theme-tokens.mjs", "test:contrast": "vitest --config packages/web-ui/vitest.config.ts run packages/web-ui/tests/utils/themeContrast.test.ts", @@ -110,6 +111,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/create-plugin/scripts/smoke.ts b/packages/create-plugin/scripts/smoke.ts index 1b291354a..186598f4b 100644 --- a/packages/create-plugin/scripts/smoke.ts +++ b/packages/create-plugin/scripts/smoke.ts @@ -59,7 +59,7 @@ function scaffoldOne(kind: 'network' | 'file', withUi: boolean): void { const manifest = JSON.parse(readFileSync(join(target, '.tabularium'), 'utf8')) as { ui_extensions?: Array<{api_version?: string}>; }; - if (manifest.ui_extensions?.[0]?.api_version !== '0.2.0') { + if (!/^\d+\.\d+\.\d+/.test(manifest.ui_extensions?.[0]?.api_version ?? '')) { throw new Error('UI extension manifest is missing plugin API compatibility metadata'); } } diff --git a/packages/create-plugin/src/cli.ts b/packages/create-plugin/src/cli.ts index a08b17358..ceca5dc7c 100644 --- a/packages/create-plugin/src/cli.ts +++ b/packages/create-plugin/src/cli.ts @@ -11,7 +11,7 @@ import {titleCase, validateDbType, validateName, validateQuote} from './validate const PACKAGE_VERSION = (createRequire(import.meta.url)('../package.json') as {version: string}) .version; // Caret range written into scaffolded plugins; bump alongside packages/plugin-api. -const PLUGIN_API_VERSION = '0.2.0'; +const PLUGIN_API_VERSION = '0.4.0'; const MIN_TABULARIS_VERSION = '0.9.20'; function main(argv: string[]): number { diff --git a/packages/create-plugin/tsconfig.json b/packages/create-plugin/tsconfig.json index 17dea758a..60e66e3a2 100644 --- a/packages/create-plugin/tsconfig.json +++ b/packages/create-plugin/tsconfig.json @@ -4,6 +4,7 @@ "module": "ESNext", "moduleResolution": "bundler", "lib": ["ES2022"], + "types": ["node"], "strict": true, "noImplicitAny": true, "noUnusedLocals": true, @@ -20,7 +21,7 @@ }, "verbatimModuleSyntax": true, "outDir": "dist", - "rootDir": "src" + "rootDir": "../.." }, "include": ["src/**/*"], "exclude": ["dist", "node_modules", "scripts", "tests", "templates"] 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 83401c2da..ca6ceb6ec 100644 --- a/packages/explain/src/parsers/source.ts +++ b/packages/explain/src/parsers/source.ts @@ -93,8 +93,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); } /** @@ -104,16 +104,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(raw: string, engine: ExplainEngine | null): ExplainSourceFormat { +export async function detectFormatFor( + raw: string, + engine: ExplainEngine | null, +): 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 ' + @@ -122,16 +125,14 @@ export function detectFormatFor(raw: string, engine: ExplainEngine | null): Expl 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}'`); } @@ -150,10 +151,10 @@ function detectPostgresFormatOrNull(raw: string): BuiltinExplainSourceFormat | n 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()) { @@ -166,7 +167,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. } @@ -190,13 +191,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(raw: string, engine: ExplainEngine | null): ExplainPlan { - return parserFor(detectFormatFor(raw, engine)).parse(raw); +export async function parseExplainFor( + raw: string, + engine: ExplainEngine | null, +): 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 0794678e9..e69598aa7 100644 --- a/packages/explain/src/raw.ts +++ b/packages/explain/src/raw.ts @@ -43,7 +43,7 @@ export type ExplainQueryOutput = {kind: 'raw'; raw: RawExplainOutput} | {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( @@ -52,7 +52,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, @@ -61,6 +61,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 64ce428fc..fe254158c 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 3d1ca6050..f015b87e4 100644 --- a/packages/explain/tests/parsers/postgres.test.ts +++ b/packages/explain/tests/parsers/postgres.test.ts @@ -45,22 +45,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(() => detectFormat('Seq Scan on users (cost=0.00..12.34 rows=100)')).toThrow( - /Unsupported/, - ); + it('rejects plain text without a cost header', async () => { + await expect( + detectFormat('Seq Scan on users (cost=0.00..12.34 rows=100)'), + ).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'); }); }); @@ -109,20 +109,22 @@ 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(/Unsupported/); + it('rejects an unsupported format', async () => { + await expect(parseExplain('-> Nested Loop (cost=0.00..1.23)')).rejects.toThrow( + /Unsupported/, + ); }); }); @@ -187,8 +189,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 679067948..304e0d5f9 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,58 +54,64 @@ 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(/buildSqliteTree/); + 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(/query_block/); + it('reports MySQL JSON missing its query_block', async () => { + await expect(parseExplainFor('{"not_a_block": 1}', 'mysql')).rejects.toThrow( + /query_block/, + ); }); it('maps driver names onto engines', () => { @@ -123,15 +129,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 d1ebed69c..9b6d5d615 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,15 +96,15 @@ 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', @@ -133,11 +133,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" } }', diff --git a/packages/explain/tests/registry.test.ts b/packages/explain/tests/registry.test.ts index ce7a0a8de..ed7091472 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,18 +104,18 @@ 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('Acme Scan'); + 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', @@ -137,7 +137,7 @@ describe('EXPLAIN parser registry', () => { ); expect(getExplainParser('acme-plan')).toBe(replacement); 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', () => { @@ -164,14 +164,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', @@ -180,11 +180,11 @@ describe('EXPLAIN parser registry', () => { sniff, }); - expect(detectFormat('[{ "Plan": { "Node Type": "Result" } }]')).toBe('postgres-json'); + 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', @@ -206,8 +206,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', () => { @@ -225,7 +225,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', @@ -235,6 +235,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 0b6ed76fb..6f6ca0774 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -35,14 +35,39 @@ export default MySlot.component; | Hook | Returns | Purpose | |------|---------|---------| -| `usePluginQuery()` | `{ executeQuery, loading, error }` | Execute read-only queries on the active connection | +| `usePluginQuery()` | `{ executeQuery, loading, error }` | Execute classified read-only SQL on supported built-in drivers; unsupported drivers reject the call | | `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 | +| `exchangeGoogleOAuthToken(request)` | `Promise` | Exchange or refresh Google OAuth tokens through the fixed Google token endpoint | + +Plugin UI runs in a sandboxed iframe and has no direct Tauri `invoke` access. +The host exposes only the operations represented by these APIs. The guest CSP +blocks plugin `fetch`/XHR connections; it does not prevent the iframe from +navigating itself. Google OAuth is proxied to one fixed endpoint, with an +allow-list of form fields. Modal content stays inside the plugin iframe, and +the host expands that iframe to a full-viewport overlay while the modal is open. +Setting writes are asynchronous; await `setSetting` or `setSettings` when +subsequent work depends on persistence. + +`usePluginQuery` rejects statement types that the host classifier recognizes as +writes. The host repeats this check with its Rust classifier and runs the query +in a database-enforced read-only context for built-in SQLite, PostgreSQL, and +MySQL drivers. Drivers that do not support this mode reject the call. Use +read-only database credentials as an additional safeguard, especially when +the database exposes custom side-effecting functions. The API returns all rows +provided by the driver; it does not silently cap results at 100 rows. Add a +`LIMIT` clause when a plugin only needs a bounded result set. + +For a Google OAuth token exchange, import `exchangeGoogleOAuthToken` and pass +the form fields needed for an `authorization_code` or `refresh_token` grant. +The host fixes the destination to `https://oauth2.googleapis.com/token`; plugins +cannot choose a URL or HTTP method. Replace direct `fetch` token exchanges with +this API when migrating an OAuth UI extension. All hooks must run inside a React component loaded by Tabularis. Calling them outside the host throws a clear error instead of failing silently. @@ -59,6 +84,8 @@ assertHostCompat(); // throws if the running Tabularis is older than MIN_HOST_VE |-----------------|-------------------|-------| | `0.1.0` | `0.1.0` | Initial release | | `0.1.1` | `0.1.0` | Typed slots and automatic manifest compatibility gate | +| `0.3.0` | `0.3.0` | Sandboxed UI API and callback RPC | +| `0.4.0` | `0.4.0` | Secret-field slot callback and fixed Google OAuth exchange | ## Slot reference diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json index 5cb0f73ab..6e5978624 100644 --- a/packages/plugin-api/package.json +++ b/packages/plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/plugin-api", - "version": "0.3.0", + "version": "0.4.0", "description": "Public API surface for Tabularis plugin UI extensions.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/plugin-api", diff --git a/packages/plugin-api/src/hooks.ts b/packages/plugin-api/src/hooks.ts index e298f8cc2..9c1ce7d6f 100644 --- a/packages/plugin-api/src/hooks.ts +++ b/packages/plugin-api/src/hooks.ts @@ -7,10 +7,13 @@ import type { UsePluginThemeReturn, UsePluginToastReturn, PluginTranslator, + GoogleOAuthTokenRequest, + GoogleOAuthTokenResponse, } from './types'; /** - * Execute read-only SQL queries against the active connection. + * Execute SQL against the active connection after a best-effort statement filter. + * This is not a security boundary; use read-only database grants for that guarantee. * Returns `{ executeQuery, loading, error }` — `executeQuery` resolves with * `{ columns, rows }` or throws a plain Error with the host's error message. */ @@ -69,3 +72,10 @@ export function usePluginTheme(): UsePluginThemeReturn { export async function openUrl(url: string): Promise { return getHost().openUrl(url); } + +/** Exchange or refresh Google OAuth tokens through the host's fixed token endpoint. */ +export function exchangeGoogleOAuthToken( + request: GoogleOAuthTokenRequest, +): Promise { + return getHost().exchangeGoogleOAuthToken(request); +} diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 67535c655..4f7be9f1c 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -15,6 +15,7 @@ export { usePluginModal, usePluginTheme, openUrl, + exchangeGoogleOAuthToken, } from './hooks'; export {getHost, assertHostCompat} from './host'; @@ -38,6 +39,8 @@ export type { PluginConfig, ThemeColors, PluginQueryResult, + GoogleOAuthTokenRequest, + GoogleOAuthTokenResponse, PluginTranslator, TabularisHostApi, UsePluginQueryReturn, diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index 1124ac751..c62f9d371 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -86,6 +86,9 @@ export type SlotContextMap = { }; 'connection-modal.connection_content': { driver: string; + database: string; + connectionName: string; + onDatabaseChange: (value: string) => void; }; 'connection-modal.extra_fields': { driver: string; diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts index 2a233ccd7..424548e4c 100644 --- a/packages/plugin-api/src/types.ts +++ b/packages/plugin-api/src/types.ts @@ -71,6 +71,24 @@ export interface PluginQueryResult { rows: unknown[][]; } +export interface GoogleOAuthTokenRequest { + grant_type: 'authorization_code' | 'refresh_token'; + code?: string; + client_id: string; + client_secret?: string; + redirect_uri?: string; + code_verifier?: string; + refresh_token?: string; +} + +export interface GoogleOAuthTokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + scope?: string; + token_type?: string; +} + export interface UsePluginQueryReturn { executeQuery: (query: string) => Promise; loading: boolean; @@ -91,8 +109,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 { @@ -127,4 +145,7 @@ export interface TabularisHostApi { usePluginModal: () => UsePluginModalReturn; usePluginTheme: () => UsePluginThemeReturn; openUrl: (url: string) => Promise; + exchangeGoogleOAuthToken: ( + request: GoogleOAuthTokenRequest, + ) => Promise; } diff --git a/packages/plugin-api/src/version.ts b/packages/plugin-api/src/version.ts index 66825a8b5..cbdf06790 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.4.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.4.0'; diff --git a/packages/plugin-guest/build.mjs b/packages/plugin-guest/build.mjs new file mode 100644 index 000000000..a92d66c82 --- /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..9b4b12f53 --- /dev/null +++ b/packages/plugin-guest/src/runtime.ts @@ -0,0 +1,469 @@ +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 '../../web-ui/src/plugins/sandbox/rpc'; +import {hydratePluginSlotContext} from '../../web-ui/src/plugins/sandbox/slotContext'; + +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.match(/([^/]+)\/__guest__\.html$/)?.[1] ?? '', +); +// Resolved against the guest page so it works under any transport base path. +function pluginModuleUrl(): string { + return new URL(modulePath.replace(/^\.\//, '').replace(/^\//, ''), location.href).href; +} +const QUERY_RPC_TIMEOUT_MS = 5 * 60_000; +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({ + // Compatibility for bundles built against older plugin-api versions that + // called getHost() for host APIs. Return only this restricted guest facade. + getHost: () => guestApi, + 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], QUERY_RPC_TIMEOUT_MS)) 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]; + const {defaultValue, ...interpolations} = options ?? {}; + let translated = + typeof value === 'string' + ? value + : typeof defaultValue === 'string' + ? defaultValue + : key; + for (const [name, replacement] of Object.entries(interpolations)) { + 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), + exchangeGoogleOAuthToken: (request: unknown) => + callHostResult('api.oauth.googleToken', request), +}); + +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 callHostResult(method: string, ...args: unknown[]): Promise { + if (!rpcPort) return Promise.reject(new Error('Plugin host connection is unavailable')); + return rpcPort.call(method, args) as Promise; +} + +window.addEventListener('securitypolicyviolation', (event) => { + if (pluginId !== 'google-sheets' || event.effectiveDirective !== 'connect-src') return; + try { + if (new URL(event.blockedURI).origin !== 'https://oauth2.googleapis.com') return; + } catch { + return; + } + void callHost('api.compatibility.legacyGoogleOAuth').catch(() => 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), + }), + [], + ); + React.useEffect(() => { + void callHost('ui.modal', modal !== null).catch(() => undefined); + }, [modal]); + 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: themeColor('bg', 'elevated', '#111827'), + color: themeColor('text', 'primary', '#f9fafb'), + border: `1px solid ${themeColor('border', 'default', '#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) modalApi.closeModal(); + }, + }, + React.createElement( + 'section', + { + role: 'dialog', + 'aria-modal': true, + 'aria-label': modal.title, + style: dialogStyle, + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Escape') modalApi.closeModal(); + }, + 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: modalApi.closeModal, + 'aria-label': 'Close dialog', + }, + '×', + ), + ), + modal.content, + ), + ), + ); +} + +const THEME_CSS_VARIABLES: Record> = { + bg: { + base: '--bg-base', + elevated: '--bg-elevated', + overlay: '--bg-overlay', + input: '--bg-input', + tooltip: '--bg-tooltip', + }, + surface: { + primary: '--surface-primary', + secondary: '--surface-secondary', + tertiary: '--surface-tertiary', + hover: '--surface-hover', + active: '--surface-active', + disabled: '--surface-disabled', + }, + text: { + primary: '--text-primary', + secondary: '--text-secondary', + muted: '--text-muted', + disabled: '--text-disabled', + accent: '--text-accent', + inverse: '--text-inverse', + }, + accent: { + primary: '--accent-primary', + secondary: '--accent-secondary', + success: '--accent-success', + warning: '--accent-warning', + error: '--accent-error', + info: '--accent-info', + }, + border: { + subtle: '--border-subtle', + default: '--border-default', + strong: '--border-strong', + focus: '--border-focus', + }, + semantic: { + string: '--semantic-string', + number: '--semantic-number', + boolean: '--semantic-boolean', + date: '--semantic-date', + null: '--semantic-null', + primaryKey: '--semantic-pk', + foreignKey: '--semantic-fk', + index: '--semantic-index', + modified: '--semantic-modified', + deleted: '--semantic-deleted', + new: '--semantic-new', + }, +}; + +function applyGuestTheme(): void { + const theme = asRecord(currentContext.theme); + const colors = asRecord(theme.colors); + const root = document.documentElement; + root.style.colorScheme = theme.isDark === true ? 'dark' : 'light'; + for (const [group, tokens] of Object.entries(THEME_CSS_VARIABLES)) { + const values = asRecord(colors[group]); + for (const [token, variable] of Object.entries(tokens)) { + const value = values[token]; + if (typeof value === 'string') root.style.setProperty(variable, value); + } + } + const textColor = asRecord(colors.text).primary; + if (typeof textColor === 'string') document.body.style.color = textColor; +} + +function themeColor(group: string, token: string, fallback: string): string { + const colors = asRecord(asRecord(currentContext.theme).colors); + const value = asRecord(colors[group])[token]; + return typeof value === 'string' ? value : fallback; +} + +Object.assign(guestWindow, { + React, + ReactJSXRuntime, + __TABULARIS_EXPLAIN__: explainApi, + __TABULARIS_API__: guestApi, + __TABULARIS_API_VERSION__: '0.4.0', +}); + +function loadExplainModule(): Promise[]> { + if (explainModulePromise) return explainModulePromise; + explainModulePromise = new Promise((resolve, reject) => { + const pluginScript = document.createElement('script'); + pluginScript.src = pluginModuleUrl(); + 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 = hydratePluginSlotContext( + next as Record, + (callback, ...values) => { + void rpc.call(callback, values).catch(() => undefined); + }, + ); + applyGuestTheme(); + renderPlugin?.(); + } + return null; + } + throw new Error('method_not_allowed'); + }); + rpcPort = rpc; + + if (mode === 'explain') return; + + const pluginScript = document.createElement('script'); + pluginScript.src = pluginModuleUrl(); + 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/packages/web-ui/src/api/contract.ts b/packages/web-ui/src/api/contract.ts index 8fee74c76..6924eba69 100644 --- a/packages/web-ui/src/api/contract.ts +++ b/packages/web-ui/src/api/contract.ts @@ -278,6 +278,10 @@ export interface ExecuteQueryRequest extends MetadataRequest { page?: number; } +export interface ExecutePluginReadOnlyQueryRequest extends MetadataRequest { + query: string; +} + export interface ExecuteQueryBatchRequest extends MetadataRequest { sessionId?: string; queries: string[]; @@ -924,6 +928,11 @@ export interface CommandMap { >; execute_query: CommandDefinition; + execute_plugin_read_only_query: CommandDefinition< + ExecutePluginReadOnlyQueryRequest, + QueryResult, + 'database' + >; execute_query_batch: CommandDefinition< ExecuteQueryBatchRequest, BatchStatementResult[], diff --git a/packages/web-ui/src/components/modals/AiApprovalModal.tsx b/packages/web-ui/src/components/modals/AiApprovalModal.tsx index 11e762978..8f3a6aae0 100644 --- a/packages/web-ui/src/components/modals/AiApprovalModal.tsx +++ b/packages/web-ui/src/components/modals/AiApprovalModal.tsx @@ -72,10 +72,16 @@ export function AiApprovalModal({approval, onApprove, onDeny, onClose}: AiApprov 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/packages/web-ui/src/contexts/PluginSlotProvider.tsx b/packages/web-ui/src/contexts/PluginSlotProvider.tsx index 2ecb42af3..7f6a2b8ea 100644 --- a/packages/web-ui/src/contexts/PluginSlotProvider.tsx +++ b/packages/web-ui/src/contexts/PluginSlotProvider.tsx @@ -1,7 +1,5 @@ import {useState, useCallback, useMemo, useEffect, useRef} from 'react'; -import * as React from 'react'; -import * as ReactJSXRuntime from 'react/jsx-runtime'; -import * as explainApi from '@tabularis/explain'; +import type {ReactNode} from 'react'; import i18n from 'i18next'; import {PluginSlotContext} from './PluginSlotContext'; @@ -9,31 +7,16 @@ 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 {SandboxedPluginFrame} from '@/plugins/sandbox/SandboxedPluginFrame'; import type {TabularisClient} from '@/api/client'; -import * as pluginApi from '@/pluginApi'; import {API_VERSION} from '@/pluginApiVersion'; import {isPluginApiCompatible} from '@/utils/pluginApiCompatibility'; import {useSettings} from '@/hooks/useSettings'; +import {disposePluginExplainParsers, loadPluginExplainParsers} from '@/utils/pluginExplainLoader'; import {useTabularisClient} from '@/hooks/useTabularisClient'; -import {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. - */ -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__ = API_VERSION; - (window as unknown as Record).__TABULARIS_EXPLAIN__ = explainApi; + children: ReactNode; } /** @@ -58,24 +41,12 @@ async function loadPluginTranslations(client: TabularisClient, pluginId: string) } } -/** - * 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( - client: TabularisClient, 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) { if (!isPluginApiCompatible(API_VERSION, entry.api_version)) { @@ -90,56 +61,19 @@ async function loadExternalPluginContributions( } const contributions: SlotContribution[] = []; - for (const [modulePath, entries] of byModule) { - try { - const source = await client.readPluginAsset(manifest.id, modulePath); - - // 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, - ); + const component: SlotContribution['component'] = ({context, pluginId}) => ( + + ); + for (const entry of entries) { + if (!VALID_SLOTS.has(entry.slot)) continue; + contributions.push({ + pluginId: manifest.id, + slot: entry.slot as SlotName, + component, + order: entry.order, + when: entry.driver ? (ctx: SlotContext) => ctx.driver === entry.driver : undefined, + }); } } @@ -179,7 +113,7 @@ export const PluginSlotProvider = ({children}: PluginSlotProviderProps) => { const loadedExplainFormats: string[] = []; for (const format of new Set(loadedExplainFormatsRef.current)) { - explainApi.unregisterExplainParser(format); + disposePluginExplainParsers([format]); } loadedExplainFormatsRef.current = []; @@ -192,26 +126,21 @@ export const PluginSlotProvider = ({children}: PluginSlotProviderProps) => { if (cancelled) break; try { const manifest = await client.call('get_plugin_manifest', {pluginId}); - exposePluginGlobals(); await loadPluginTranslations(client, pluginId); const parserFormats = await loadPluginExplainParsers( manifest, - (id, assetPath) => client.readPluginAsset(id, assetPath), () => !cancelled, ); if (cancelled) { for (const format of new Set(parserFormats)) { - explainApi.unregisterExplainParser(format); + disposePluginExplainParsers([format]); } break; } loadedExplainFormats.push(...parserFormats); loadedExplainFormatsRef.current = loadedExplainFormats; - const pluginContributions = await loadExternalPluginContributions( - client, - manifest, - ); + const pluginContributions = await loadExternalPluginContributions(manifest); loadedContributions.push(...pluginContributions); } catch (err) { console.error( @@ -229,7 +158,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/packages/web-ui/src/i18n/locales/de.json b/packages/web-ui/src/i18n/locales/de.json index ab11dec06..9bee0e802 100644 --- a/packages/web-ui/src/i18n/locales/de.json +++ b/packages/web-ui/src/i18n/locales/de.json @@ -446,6 +446,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", @@ -913,6 +919,8 @@ "enabled": "Aktiviert", "disabled": "Deaktiviert", "openFolder": "Plugin-Ordner öffnen", + "legacyGoogleSheetsOAuthHint": "Aktualisieren Sie das Google-Sheets-Plugin, um die Google-Anmeldung mit dieser Tabularis-Version wiederherzustellen.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Installation fehlgeschlagen", "subtitle": "Bei der Installation des Plugins ist ein Fehler aufgetreten. Siehe Details unten.", diff --git a/packages/web-ui/src/i18n/locales/en.json b/packages/web-ui/src/i18n/locales/en.json index c1b2c8373..09bf74516 100644 --- a/packages/web-ui/src/i18n/locales/en.json +++ b/packages/web-ui/src/i18n/locales/en.json @@ -459,6 +459,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", @@ -978,6 +984,8 @@ "enabled": "Enabled", "disabled": "Disabled", "openFolder": "Open plugins folder", + "legacyGoogleSheetsOAuthHint": "Update the Google Sheets plugin to restore Google sign-in in this Tabularis version.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Installation Failed", "subtitle": "An error occurred while installing the plugin. See the details below.", diff --git a/packages/web-ui/src/i18n/locales/es.json b/packages/web-ui/src/i18n/locales/es.json index abeb04706..cc18f6633 100644 --- a/packages/web-ui/src/i18n/locales/es.json +++ b/packages/web-ui/src/i18n/locales/es.json @@ -466,6 +466,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", @@ -938,6 +944,8 @@ "enabled": "Habilitado", "disabled": "Deshabilitado", "openFolder": "Abrir carpeta de plugins", + "legacyGoogleSheetsOAuthHint": "Actualiza el plugin de Google Sheets para restaurar el inicio de sesión de Google en esta versión de Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Instalación fallida", "subtitle": "Ocurrió un error al instalar el plugin. Consulta los detalles a continuación.", diff --git a/packages/web-ui/src/i18n/locales/fr.json b/packages/web-ui/src/i18n/locales/fr.json index 487e49740..cdd749ab6 100644 --- a/packages/web-ui/src/i18n/locales/fr.json +++ b/packages/web-ui/src/i18n/locales/fr.json @@ -459,6 +459,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", @@ -946,6 +952,8 @@ "enabled": "Activé", "disabled": "Désactivé", "openFolder": "Ouvrir le dossier des plugins", + "legacyGoogleSheetsOAuthHint": "Mettez à jour le plugin Google Sheets pour rétablir la connexion Google dans cette version de Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Échec de l’installation", "subtitle": "Une erreur s’est produite lors de l’installation du plugin. Voir les détails ci-dessous.", diff --git a/packages/web-ui/src/i18n/locales/it.json b/packages/web-ui/src/i18n/locales/it.json index 5bf254b69..15be4dfe7 100644 --- a/packages/web-ui/src/i18n/locales/it.json +++ b/packages/web-ui/src/i18n/locales/it.json @@ -473,6 +473,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", @@ -973,6 +979,8 @@ "enabled": "Abilitato", "disabled": "Disabilitato", "openFolder": "Apri cartella plugin", + "legacyGoogleSheetsOAuthHint": "Aggiorna il plugin Google Sheets per ripristinare l’accesso Google in questa versione di Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Installazione fallita", "subtitle": "Si è verificato un errore durante l'installazione del plugin. Vedi i dettagli qui sotto.", diff --git a/packages/web-ui/src/i18n/locales/ja.json b/packages/web-ui/src/i18n/locales/ja.json index 33c1e5695..7f0ac9ada 100644 --- a/packages/web-ui/src/i18n/locales/ja.json +++ b/packages/web-ui/src/i18n/locales/ja.json @@ -459,6 +459,12 @@ "import": "接続をインポート", "exportTitle": "接続をエクスポート", "exportWarning": "エクスポートされたファイルには、データベースおよび SSH のパスワードが平文で含まれます。安全な場所に保管してください。", + "exportSecretsConfirmation": { + "title": "シークレットのエクスポートを確認", + "message": "保存済みのパスワード、SSH キー、接続 URI を平文でエクスポートしますか?", + "export": "エクスポート", + "cancel": "キャンセル" + }, "exportModal": { "title": "接続をエクスポート", "subtitle": "接続のエクスポート方法を選択してください", @@ -927,6 +933,8 @@ "enabled": "有効", "disabled": "無効", "openFolder": "プラグインフォルダーを開く", + "legacyGoogleSheetsOAuthHint": "この Tabularis バージョンで Google ログインを再開するには、Google Sheets プラグインを更新してください。", + "closePluginOverlay": "Close plugin window", "installError": { "title": "インストール失敗", "subtitle": "プラグインのインストール中にエラーが発生しました。詳細は下記を参照してください。", diff --git a/packages/web-ui/src/i18n/locales/ko.json b/packages/web-ui/src/i18n/locales/ko.json index 19fc771f2..76b1e3f32 100644 --- a/packages/web-ui/src/i18n/locales/ko.json +++ b/packages/web-ui/src/i18n/locales/ko.json @@ -429,6 +429,12 @@ "import": "연결 가져오기", "exportTitle": "연결 내보내기", "exportWarning": "내보낸 파일에는 데이터베이스 및 SSH 비밀번호가 평문으로 포함됩니다. 안전하게 보관하세요.", + "exportSecretsConfirmation": { + "title": "비밀 정보 내보내기 확인", + "message": "저장된 비밀번호, SSH 키 및 연결 URI를 일반 텍스트로 내보낼까요?", + "export": "내보내기", + "cancel": "취소" + }, "importFromApp": { "title": "앱에서 가져오기", "subtitle": "설치된 다른 SQL 클라이언트에서 연결을 가져옵니다", @@ -891,6 +897,8 @@ "enabled": "활성화됨", "disabled": "비활성화됨", "openFolder": "플러그인 폴더 열기", + "legacyGoogleSheetsOAuthHint": "이 Tabularis 버전에서 Google 로그인을 복원하려면 Google Sheets 플러그인을 업데이트하세요.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "설치 실패", "subtitle": "플러그인을 설치하는 중 오류가 발생했습니다. 아래 세부 정보를 확인하세요.", diff --git a/packages/web-ui/src/i18n/locales/pt-BR.json b/packages/web-ui/src/i18n/locales/pt-BR.json index 9b0fbd52e..116da948c 100644 --- a/packages/web-ui/src/i18n/locales/pt-BR.json +++ b/packages/web-ui/src/i18n/locales/pt-BR.json @@ -430,6 +430,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", @@ -926,6 +932,8 @@ "enabled": "Ativado", "disabled": "Desativado", "openFolder": "Abrir pasta de plugins", + "legacyGoogleSheetsOAuthHint": "Atualize o plugin Google Sheets para restaurar o login do Google nesta versão do Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Falha na Instalação", "subtitle": "Ocorreu um erro ao instalar o plugin. Veja os detalhes abaixo.", diff --git a/packages/web-ui/src/i18n/locales/ru.json b/packages/web-ui/src/i18n/locales/ru.json index 7b7101259..f4d10f219 100644 --- a/packages/web-ui/src/i18n/locales/ru.json +++ b/packages/web-ui/src/i18n/locales/ru.json @@ -462,6 +462,12 @@ "import": "Импортировать подключения", "exportTitle": "Экспорт подключений", "exportWarning": "Экспортируемый файл будет содержать пароли к базам данных и SSH в открытом виде. Храните его в надёжном месте!", + "exportSecretsConfirmation": { + "title": "Подтвердите экспорт секретов", + "message": "Экспортировать сохранённые пароли, SSH-ключи и URI подключений в открытом виде?", + "export": "Экспортировать", + "cancel": "Отмена" + }, "exportModal": { "title": "Экспорт подключений", "subtitle": "Выберите способ экспорта подключений", @@ -908,6 +914,8 @@ "enabled": "Включён", "disabled": "Отключён", "openFolder": "Открыть папку плагинов", + "legacyGoogleSheetsOAuthHint": "Обновите плагин Google Sheets, чтобы восстановить вход через Google в этой версии Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Ошибка установки", "subtitle": "Ошибка при установке плагина. Подробности ниже.", diff --git a/packages/web-ui/src/i18n/locales/tl.json b/packages/web-ui/src/i18n/locales/tl.json index 0bfcac674..7962eddf8 100644 --- a/packages/web-ui/src/i18n/locales/tl.json +++ b/packages/web-ui/src/i18n/locales/tl.json @@ -459,6 +459,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", @@ -946,6 +952,8 @@ "enabled": "Naka-enable", "disabled": "Naka-disable", "openFolder": "Buksan ang folder ng mga plugin", + "legacyGoogleSheetsOAuthHint": "I-update ang Google Sheets plugin upang maibalik ang Google sign-in sa bersyong ito ng Tabularis.", + "closePluginOverlay": "Close plugin window", "installError": { "title": "Nabigo ang Pag-install", "subtitle": "May error habang ini-install ang plugin. Tingnan ang mga detalye sa ibaba.", diff --git a/packages/web-ui/src/i18n/locales/zh.json b/packages/web-ui/src/i18n/locales/zh.json index 11001f405..f3d03bb06 100644 --- a/packages/web-ui/src/i18n/locales/zh.json +++ b/packages/web-ui/src/i18n/locales/zh.json @@ -445,6 +445,12 @@ "import": "导入连接", "exportTitle": "导出连接", "exportWarning": "导出的文件将包含您的数据库和 SSH 明文密码。请妥善保管该文件。", + "exportSecretsConfirmation": { + "title": "确认导出机密信息", + "message": "要以明文导出已保存的密码、SSH 密钥和连接 URI 吗?", + "export": "导出", + "cancel": "取消" + }, "exportModal": { "title": "导出连接", "subtitle": "选择导出连接的方式", @@ -874,6 +880,8 @@ "enabled": "已启用", "disabled": "已禁用", "openFolder": "打开插件文件夹", + "legacyGoogleSheetsOAuthHint": "请更新 Google Sheets 插件,以在此 Tabularis 版本中恢复 Google 登录。", + "closePluginOverlay": "Close plugin window", "installError": { "title": "安装失败", "subtitle": "安装插件时发生错误。请参阅下面的详细信息。", diff --git a/packages/web-ui/src/pages/Connections.tsx b/packages/web-ui/src/pages/Connections.tsx index 4ea019f28..7ae67ecae 100644 --- a/packages/web-ui/src/pages/Connections.tsx +++ b/packages/web-ui/src/pages/Connections.tsx @@ -67,17 +67,17 @@ import {buildPluginIssueUrl, resolvePluginRepoUrl} from '@/utils/pluginIssueRepo import {APP_VERSION} from '@/version'; const NewConnectionModal = lazy(() => - import('@/components/modals/NewConnectionModal').then((m) => ({ + import('../components/modals/NewConnectionModal').then((m) => ({ default: m.NewConnectionModal, })), ); const ImportFromAppModal = lazy(() => - import('@/components/modals/ImportFromAppModal').then((m) => ({ + import('../components/modals/ImportFromAppModal').then((m) => ({ default: m.ImportFromAppModal, })), ); const MigrationChecklistModal = lazy(() => - import('@/components/modals/MigrationChecklistModal').then((m) => ({ + import('../components/modals/MigrationChecklistModal').then((m) => ({ default: m.MigrationChecklistModal, })), ); @@ -527,6 +527,7 @@ export const Connections = () => { }); await saveGeneratedFile(client, platform, generated); } catch (e) { + if (String(e).includes('SECRET_EXPORT_CANCELLED')) return; console.error('Export failed:', e); setError(toErrorMessage(e)); } diff --git a/packages/web-ui/src/pages/VisualExplainPage.tsx b/packages/web-ui/src/pages/VisualExplainPage.tsx index 1212bba24..7637fe9d7 100644 --- a/packages/web-ui/src/pages/VisualExplainPage.tsx +++ b/packages/web-ui/src/pages/VisualExplainPage.tsx @@ -69,7 +69,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], ); @@ -161,7 +161,7 @@ export const VisualExplainPage = ({ loadPlan(async () => { const contents = await platform.readInputFile(selected.reference); return withSourceLabel( - parseExplain(new TextDecoder().decode(contents)), + await parseExplain(new TextDecoder().decode(contents)), selected.name, ); }), diff --git a/packages/web-ui/src/platform/browserCapabilities.ts b/packages/web-ui/src/platform/browserCapabilities.ts index cf38e2223..457b65268 100644 --- a/packages/web-ui/src/platform/browserCapabilities.ts +++ b/packages/web-ui/src/platform/browserCapabilities.ts @@ -206,6 +206,10 @@ export class BrowserPlatformCapabilities implements PlatformCapabilities { return Promise.resolve(`/api/v1/assets/connection-icons/${encodeURIComponent(match[1])}`); } + pluginFrameBaseUrl(): string { + return '/api/v1/sandbox/plugins/'; + } + async readClipboard(): Promise { try { if (!navigator.clipboard?.readText) { diff --git a/packages/web-ui/src/platform/capabilities.ts b/packages/web-ui/src/platform/capabilities.ts index 57063ff96..e276fc2ea 100644 --- a/packages/web-ui/src/platform/capabilities.ts +++ b/packages/web-ui/src/platform/capabilities.ts @@ -165,6 +165,8 @@ export interface PlatformCapabilities { fetchBlobReference(value: unknown): Promise; fetchDatabaseBlob(request: BlobRecordRequest): Promise; resolveAppAsset(relativePath: string): Promise; + /** Base URL (ending in `/`) under which sandboxed plugin frames are served. */ + pluginFrameBaseUrl(): string; readClipboard(): Promise; writeClipboard(text: string): Promise; /** Writes an HTML flavor with `altText` as the plain-text flavor (same capability as writeClipboard). */ diff --git a/packages/web-ui/src/platform/tauriCapabilities.ts b/packages/web-ui/src/platform/tauriCapabilities.ts index 63b8133ad..804fd1718 100644 --- a/packages/web-ui/src/platform/tauriCapabilities.ts +++ b/packages/web-ui/src/platform/tauriCapabilities.ts @@ -436,6 +436,10 @@ export class TauriPlatformCapabilities implements PlatformCapabilities { return convertFileSrc(await join(await this.operations.getAppDataDir(), relativePath)); } + pluginFrameBaseUrl(): string { + return convertFileSrc('x', 'tabularis-plugin').slice(0, -1); + } + async readClipboard(): Promise { this.require('readClipboard'); return this.operations.readClipboardText(); diff --git a/packages/web-ui/src/plugins/sandbox/SandboxedPluginFrame.tsx b/packages/web-ui/src/plugins/sandbox/SandboxedPluginFrame.tsx new file mode 100644 index 000000000..a0ba87132 --- /dev/null +++ b/packages/web-ui/src/plugins/sandbox/SandboxedPluginFrame.tsx @@ -0,0 +1,376 @@ +import {useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; +import {useTranslation} from 'react-i18next'; +import {useSettings} from '@/hooks/useSettings'; +import {useToast} from '@/hooks/useToast'; +import {ThemeContext} from '@/contexts/ThemeContext'; +import {isReadOnlyQuery} from '@/utils/sqlAnalysis'; +import {useTabularisClient} from '@/hooks/useTabularisClient'; +import {getActivePlatformCapabilities} from '@/platform/activeCapabilities'; +import type {SlotContext} from '@/types/pluginSlots'; +import {connectFrame, pluginFrameUrl} from './connectFrame'; +import {serializePluginSlotContext} from './slotContext'; +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 client = useTabularisClient(); + const {settings, updateSetting} = useSettings(); + const {showToast} = useToast(); + const {i18n, t} = useTranslation(); + const language = i18n.resolvedLanguage ?? i18n.language; + const theme = useContext(ThemeContext); + const frameRef = useRef(null); + const anchorRef = useRef(null); + const rpcRef = useRef(null); + const legacyOAuthHintShown = useRef(false); + 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); + const [modalOpen, setModalOpen] = useState(false); + const [frameRect, setFrameRect] = useState({left: 0, top: 0, width: 0}); + + useLayoutEffect(() => { + const anchor = anchorRef.current; + if (!anchor) return; + const updatePosition = () => { + const rect = anchor.getBoundingClientRect(); + setFrameRect({left: rect.left, top: rect.top, width: rect.width}); + }; + updatePosition(); + const resizeObserver = new ResizeObserver(updatePosition); + resizeObserver.observe(anchor); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + resizeObserver.disconnect(); + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [height]); + + useEffect(() => { + const frame = frameRef.current; + if (!frame) return; + const controller = new AbortController(); + let disposed = false; + const handlers = { + 'ui.modal': (open: unknown) => { + if (typeof open !== 'boolean') throw new Error('Modal state must be a boolean'); + if (open && !navigator.userActivation?.isActive) { + // Expansion needs a recent user gesture (propagated from the frame to the host). + throw new Error('Modal expansion requires a user interaction'); + } + setModalOpen(open); + }, + 'ui.resize': (value: unknown) => { + const requested = typeof value === 'number' ? value : 0; + setHeight(Math.min(MAX_HEIGHT, Math.max(0, requested))); + }, + 'slot.context': () => serializePluginSlotContext(contextRef.current), + 'slot.onDatabaseChange': (value: unknown) => { + if (typeof value !== 'string') throw new Error('Database must be a string'); + const callback = contextRef.current.onDatabaseChange; + if (typeof callback !== 'function') + throw new Error('Database change is unavailable in this slot'); + callback(value); + }, + 'slot.setExtraField': (key: unknown, value: unknown) => { + if ( + typeof key !== 'string' || + !key || + key.length > 256 || + typeof value !== 'string' || + value.length > 16_384 + ) { + throw new Error('Invalid connection field value'); + } + const callback = contextRef.current.setExtraField; + if (typeof callback !== 'function') + throw new Error('Extra fields are unavailable in this slot'); + callback(key, value); + }, + 'slot.setCredentialFieldsHidden': (hidden: unknown) => { + if (typeof hidden !== 'boolean') + throw new Error('Credential visibility must be a boolean'); + const callback = contextRef.current.setCredentialFieldsHidden; + if (typeof callback !== 'function') + throw new Error('Credential fields are unavailable in this slot'); + callback(hidden); + }, + 'slot.setSecretField': (key: unknown, value: unknown) => { + const normalizedKey = typeof key === 'string' ? key.trim() : ''; + if ( + !/^[A-Za-z0-9._-]{1,64}$/.test(normalizedKey) || + typeof value !== 'string' || + value.length > 1_048_576 + ) { + throw new Error('Invalid plugin secret field'); + } + const callback = contextRef.current.setSecretField; + if (typeof callback !== 'function') + throw new Error('Secret fields are unavailable in this slot'); + callback(normalizedKey, value); + }, + '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 SQL statement type is not allowed by the host filter'); + } + const active = contextRef.current; + if (typeof active.connectionId !== 'string' || !active.connectionId) { + throw new Error('No active connection'); + } + return client.call('execute_plugin_read_only_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' || text.length === 0 || text.length > 4_000) { + throw new Error( + 'Notification text must contain between 1 and 4,000 characters', + ); + } + const toastKind = kind === 'error' || kind === 'warning' ? kind : 'info'; + showToast(text, {kind: toastKind}); + }, + '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 getActivePlatformCapabilities().openExternalUrl(url.toString()); + return null; + }, + 'api.oauth.googleToken': async (value: unknown) => { + if (pluginId !== 'google-sheets') { + throw new Error( + 'Google OAuth token exchange is only available to the Google Sheets plugin', + ); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Invalid Google OAuth token request'); + } + const input = value as Record; + const allowed = new Set([ + 'grant_type', + 'code', + 'client_id', + 'client_secret', + 'redirect_uri', + 'code_verifier', + 'refresh_token', + ]); + if (Object.keys(input).some((key) => !allowed.has(key))) { + throw new Error('Unsupported Google OAuth token field'); + } + const grantType = input.grant_type; + if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { + throw new Error('Unsupported Google OAuth grant type'); + } + const fields = new URLSearchParams(); + for (const [key, field] of Object.entries(input)) { + if (typeof field !== 'string' || !field || field.length > 16_384) { + throw new Error(`Invalid Google OAuth field: ${key}`); + } + fields.set(key, field); + } + if ( + typeof input.client_id !== 'string' || + (grantType === 'authorization_code' && + (typeof input.code !== 'string' || + typeof input.redirect_uri !== 'string')) || + (grantType === 'refresh_token' && typeof input.refresh_token !== 'string') + ) { + throw new Error('Missing required Google OAuth fields'); + } + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: fields, + }); + if (!response.ok) + throw new Error(`Google OAuth token request failed (${response.status})`); + const token = (await response.json()) as Record; + if ( + typeof token.access_token !== 'string' || + typeof token.expires_in !== 'number' + ) { + throw new Error('Google returned an invalid token response'); + } + return { + access_token: token.access_token, + expires_in: token.expires_in, + ...(typeof token.refresh_token === 'string' + ? {refresh_token: token.refresh_token} + : {}), + ...(typeof token.scope === 'string' ? {scope: token.scope} : {}), + ...(typeof token.token_type === 'string' ? {token_type: token.token_type} : {}), + }; + }, + 'api.compatibility.legacyGoogleOAuth': () => { + if (pluginId !== 'google-sheets' || legacyOAuthHintShown.current) return; + legacyOAuthHintShown.current = true; + showToast(t('plugins.legacyGoogleSheetsOAuthHint'), {kind: 'warning'}); + }, + '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', [ + serializePluginSlotContext(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; + }; + }, [client, module, pluginId, showToast, t, updateSetting]); + + useEffect(() => { + contextRef.current = guestContext; + void rpcRef.current + ?.call('slot.context', [serializePluginSlotContext(guestContext)]) + .catch(() => undefined); + }, [guestContext]); + + useEffect(() => { + if (!modalOpen) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setModalOpen(false); + }; + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }, [modalOpen]); + + const iframe = ( +