diff --git a/packages/create-plugin/README.md b/packages/create-plugin/README.md index 69b769c6..a4059dd4 100644 --- a/packages/create-plugin/README.md +++ b/packages/create-plugin/README.md @@ -12,7 +12,7 @@ npm create @tabularis/plugin@latest my-driver A runnable Rust project with: -- **`manifest.json`** aligned with the Tabularis plugin schema. +- **`.tabularium`** bundle manifest aligned with the Tabularis plugin schema. - **33 JSON-RPC handlers pre-wired** — metadata methods return empty arrays (plugin loads cleanly), query/CRUD/DDL methods return `-32601` until you implement them. - **`test_connection` placeholder** that returns success, so your driver appears in the connection picker immediately after `just dev-install`. - **Working utilities**: `quote_identifier`, `paginate` — with unit tests — ready to use from your handlers. @@ -68,12 +68,43 @@ just dev-install # builds and installs into ~/.local/share/tabulari From there, fill in handlers in `src/handlers/metadata.rs`, then `query.rs`, then the rest. The generated `README.md` includes a feature-by-feature roadmap. +## Migrating an existing plugin + +Plugins built before the registry cutover ship a `manifest.json`. The host now +reads a `.tabularium` bundle manifest (the `manifest.json` path survives only as +a deprecated fallback). To convert a project in place: + +```bash +npx @tabularis/create-plugin migrate # current directory +npx @tabularis/create-plugin migrate ./my-driver +``` + +This writes `.tabularium` from your `manifest.json`, removes the old file, and +updates the `manifest.json` references in `release.yml`, `justfile`, and +`README.md`. It keeps a `id` that differs from `name` (the host uses it as the +plugin identity) and refuses to run if the manifest has no semver `version`, +which the registry requires. + +By default the release workflow is left as-is (only its `manifest.json` +reference is renamed so the build keeps working). The hosted Tabularium registry +resolves the manifest from the **release assets**, so it needs `.tabularium` +published as a standalone asset. Add `--ci` to regenerate `release.yml` from the +registry-ready template: + +```bash +npx @tabularis/create-plugin migrate ./my-driver --ci +``` + +`--ci` overwrites `release.yml` (re-apply any custom CI steps) and derives the +binary name from the manifest's `executable` field. Commit the result and +republish. + ## Layout of the generated project ``` my-driver/ ├── Cargo.toml -├── manifest.json +├── .tabularium ├── README.md ├── justfile # just build / test / dev-install / repl / lint / fmt ├── rust-toolchain.toml diff --git a/packages/create-plugin/package.json b/packages/create-plugin/package.json index 93aebdd7..03be5f17 100644 --- a/packages/create-plugin/package.json +++ b/packages/create-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@tabularis/create-plugin", - "version": "0.1.1", + "version": "0.2.0", "description": "Scaffold a new Tabularis database driver plugin in seconds.", "license": "Apache-2.0", "homepage": "https://github.com/TabularisDB/tabularis/tree/main/packages/create-plugin", diff --git a/packages/create-plugin/scripts/smoke.ts b/packages/create-plugin/scripts/smoke.ts index 5b5112bf..108e529c 100644 --- a/packages/create-plugin/scripts/smoke.ts +++ b/packages/create-plugin/scripts/smoke.ts @@ -37,7 +37,7 @@ function scaffoldOne(kind: "network" | "file", withUi: boolean): void { if (withUi) args.push("--with-ui"); run(process.execPath, [CLI, ...args], dir); - for (const expected of ["Cargo.toml", "manifest.json", "src/main.rs", "justfile"]) { + for (const expected of ["Cargo.toml", ".tabularium", "src/main.rs", "justfile"]) { const p = join(target, expected); if (!existsSync(p) || statSync(p).size === 0) { throw new Error(`missing or empty: ${p}`); diff --git a/packages/create-plugin/src/cli.ts b/packages/create-plugin/src/cli.ts index 41b4fe87..295558ac 100644 --- a/packages/create-plugin/src/cli.ts +++ b/packages/create-plugin/src/cli.ts @@ -1,7 +1,8 @@ import { parseArgs } from "node:util"; import { resolve } from "node:path"; -import { printCreated, printError, printHelp } from "./print"; +import { migratePlugin } from "./migrate"; +import { printCreated, printError, printHelp, printMigrated } from "./print"; import { scaffold } from "./scaffold"; import { titleCase, validateDbType, validateName, validateQuote } from "./validate"; @@ -20,6 +21,7 @@ function main(argv: string[]): number { quote: { type: "string" }, "with-ui": { type: "boolean", default: false }, "no-git": { type: "boolean", default: false }, + ci: { type: "boolean", default: false }, dir: { type: "string" }, version: { type: "boolean", short: "v", default: false }, help: { type: "boolean", short: "h", default: false }, @@ -41,6 +43,10 @@ function main(argv: string[]): number { return 0; } + if (parsed.positionals[0] === "migrate") { + return runMigrate(parsed); + } + const rawName = parsed.positionals[0]; if (!rawName) { printError("missing argument"); @@ -92,5 +98,23 @@ function main(argv: string[]): number { return 0; } +/** + * `migrate [path]` — convert a legacy `manifest.json` plugin to a `.tabularium` + * bundle in place. Operates on the given path, or `--dir`, or the cwd. + */ +function runMigrate(parsed: ReturnType): number { + const target = resolve( + parsed.positionals[1] ?? (parsed.values.dir as string | undefined) ?? process.cwd(), + ); + try { + const result = migratePlugin(target, { ci: Boolean(parsed.values.ci) }); + printMigrated(target, result); + return 0; + } catch (err) { + printError(err instanceof Error ? err.message : String(err)); + return 1; + } +} + const exitCode = main(process.argv.slice(2)); if (exitCode !== 0) process.exit(exitCode); diff --git a/packages/create-plugin/src/migrate.ts b/packages/create-plugin/src/migrate.ts new file mode 100644 index 00000000..249ae12a --- /dev/null +++ b/packages/create-plugin/src/migrate.ts @@ -0,0 +1,170 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { substitute } from "./substitute"; + +/** Semver as the registry accepts it: MAJOR.MINOR.PATCH, optional pre-release/build. No leading "v". */ +const SEMVER_RE = /^\d+\.\d+\.\d+(?:[-+].+)?$/; + +const RELEASE_WORKFLOW = ".github/workflows/release.yml"; + +/** Files whose `manifest.json` references must follow the rename (release.yml only when not regenerated). */ +const REFERENCE_FILES = ["justfile", "README.md"]; + +export interface MigrateOptions { + /** + * Regenerate `.github/workflows/release.yml` from the registry-ready template + * instead of only renaming its `manifest.json` reference. Overwrites the file. + */ + ci?: boolean; +} + +export interface MigrateResult { + /** Human-readable list of what changed, in apply order. */ + changed: string[]; + /** Non-fatal notices (e.g. a load-bearing `id` that was deliberately kept). */ + warnings: string[]; + /** True when the release workflow was regenerated from the template (`--ci`). */ + ciRegenerated: boolean; +} + +/** Template root: `../templates` from this module, in both `src/` (tests) and `dist/` (published). */ +function templateRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../templates"); +} + +/** Rename `manifest.json` → `.tabularium` references in a file, if present. */ +function patchReferences(dir: string, rel: string, changed: string[]): void { + const p = join(dir, rel); + if (!existsSync(p)) return; + const before = readFileSync(p, "utf8"); + // "manifest.json" is not a substring of "manifest.schema.json", so schema + // links are left untouched. + const after = before.split("manifest.json").join(".tabularium"); + if (after !== before) { + writeFileSync(p, after, "utf8"); + changed.push(`${rel} (references updated)`); + } +} + +/** Render the registry-ready release workflow from the template with the plugin's binary name. */ +function regenerateReleaseWorkflow(dir: string, binName: string): void { + const tmplPath = join(templateRoot(), "rust-driver", RELEASE_WORKFLOW + ".tmpl"); + const rendered = substitute(readFileSync(tmplPath, "utf8"), { BIN_NAME: binName }); + const out = join(dir, RELEASE_WORKFLOW); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, rendered, "utf8"); +} + +/** + * Migrate a plugin project from the legacy `manifest.json` to the canonical + * `.tabularium` bundle manifest the host now reads. + * + * Hard cutover (the `COMPAT(registry-ga)` fallback in the host is meant to go + * away): writes `.tabularium`, deletes `manifest.json`, and renames the + * `manifest.json` references in the release workflow, justfile, and README. + * + * With `options.ci`, the release workflow is instead **regenerated** from the + * registry-ready template so `.tabularium` is published as a standalone release + * asset (which the Tabularium registry requires). Without it, the CI structure + * is left alone — only its `manifest.json` reference is renamed so the build + * keeps working. + * + * `id` is dropped only when it equals `name`. The host falls back to `name` for + * the plugin identity when `id` is absent, so an `id` that differs from `name` + * is load-bearing and is kept (with a warning) rather than silently changing + * the plugin's identity. + * + * Throws on a missing/invalid manifest or a missing/invalid `version` — the + * registry rejects releases whose manifest has no semver `version`. + */ +export function migratePlugin(dir: string, options: MigrateOptions = {}): MigrateResult { + const manifestPath = join(dir, "manifest.json"); + const tabulariumPath = join(dir, ".tabularium"); + + if (!existsSync(manifestPath)) { + if (existsSync(tabulariumPath)) { + return { + changed: [], + warnings: [ + "Nothing to do: a .tabularium manifest is already present and there is no manifest.json to convert.", + ], + ciRegenerated: false, + }; + } + throw new Error( + `No manifest.json found in ${dir}. Run this from a plugin project root, or pass the path (e.g. \`migrate ./my-driver\`).`, + ); + } + + let manifest: Record; + try { + manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; + } catch (err) { + throw new Error( + `manifest.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + const version = manifest.version; + if (typeof version !== "string" || !SEMVER_RE.test(version)) { + throw new Error( + `manifest.json has no valid "version" (found ${JSON.stringify(version)}). ` + + `The registry requires a semver version with no leading "v" (e.g. "1.0.0"). Add one, then migrate.`, + ); + } + + const warnings: string[] = []; + const changed: string[] = []; + let ciRegenerated = false; + + const { id, name } = manifest as { id?: unknown; name?: unknown }; + if (typeof id === "string") { + if (id === name) { + delete manifest.id; + } else { + warnings.push( + `Kept "id": ${JSON.stringify(id)} — it differs from "name" (${JSON.stringify(name)}), ` + + `so it identifies the plugin and is not redundant. Remove it manually only if you also rename the install directory.`, + ); + } + } + + writeFileSync(tabulariumPath, JSON.stringify(manifest, null, 2) + "\n", "utf8"); + changed.push(".tabularium (written)"); + + rmSync(manifestPath); + changed.push("manifest.json (removed)"); + + // Follow the rename everywhere the bundle file is referenced. + for (const rel of REFERENCE_FILES) { + patchReferences(dir, rel, changed); + } + + // Release workflow: regenerate to registry-ready only with --ci; otherwise + // just rename its reference so the existing CI keeps building. + if (options.ci) { + const executable = typeof manifest.executable === "string" ? manifest.executable.trim() : ""; + if (!executable) { + warnings.push( + `--ci skipped for ${RELEASE_WORKFLOW}: the manifest has no "executable", so the workflow's binary name can't be derived. Renamed its reference instead — upgrade the CI by hand.`, + ); + patchReferences(dir, RELEASE_WORKFLOW, changed); + } else { + const existed = existsSync(join(dir, RELEASE_WORKFLOW)); + regenerateReleaseWorkflow(dir, executable); + changed.push(`${RELEASE_WORKFLOW} (${existed ? "regenerated" : "created"} from registry-ready template)`); + if (existed) { + warnings.push( + `Overwrote ${RELEASE_WORKFLOW} from the registry-ready template — re-apply any custom CI steps you had.`, + ); + } + ciRegenerated = true; + } + } else { + patchReferences(dir, RELEASE_WORKFLOW, changed); + } + + return { changed, warnings, ciRegenerated }; +} diff --git a/packages/create-plugin/src/print.ts b/packages/create-plugin/src/print.ts index ff7a1783..2ecda732 100644 --- a/packages/create-plugin/src/print.ts +++ b/packages/create-plugin/src/print.ts @@ -1,5 +1,7 @@ import kleur from "kleur"; +import type { MigrateResult } from "./migrate"; + export function printCreated(slug: string, targetDir: string, withUi: boolean): void { console.log(""); console.log(kleur.green("✓") + " " + kleur.bold(`Created ${slug}`) + kleur.dim(` at ${targetDir}`)); @@ -17,6 +19,36 @@ export function printCreated(slug: string, targetDir: string, withUi: boolean): console.log(""); } +export function printMigrated(targetDir: string, result: MigrateResult): void { + console.log(""); + if (result.changed.length === 0) { + console.log(kleur.yellow("•") + " " + (result.warnings[0] ?? "Nothing to migrate.")); + console.log(""); + return; + } + console.log(kleur.green("✓") + " " + kleur.bold("Migrated to .tabularium") + kleur.dim(` in ${targetDir}`)); + console.log(""); + for (const change of result.changed) { + console.log(" " + kleur.dim("•") + " " + change); + } + if (result.warnings.length > 0) { + console.log(""); + for (const warning of result.warnings) { + console.log(kleur.yellow(" ! ") + warning); + } + } + console.log(""); + if (result.ciRegenerated) { + console.log(kleur.dim("Release workflow is registry-ready (publishes .tabularium as a standalone")); + console.log(kleur.dim("asset). Commit the changes and republish.")); + } else { + console.log(kleur.dim("Next: make your release workflow publish .tabularium as a standalone asset —")); + console.log(kleur.dim("the registry resolves the manifest from release assets, not the bundle zips.")); + console.log(kleur.dim("Re-run with --ci to regenerate it from the template. Then commit and republish.")); + } + console.log(""); +} + export function printError(message: string): void { console.error(kleur.red("✗ ") + message); } @@ -28,15 +60,22 @@ ${kleur.bold("@tabularis/create-plugin")} — scaffold a new Tabularis driver pl ${kleur.bold("Usage:")} npm create @tabularis/plugin@latest [--] [options] npx @tabularis/create-plugin [options] + npx @tabularis/create-plugin migrate [path] + +${kleur.bold("Commands:")} + Scaffold a new plugin (default) + migrate [path] Convert an existing manifest.json plugin to .tabularium ${kleur.bold("Arguments:")} Plugin name (slugified to lowercase with hyphens) + [path] Plugin project to migrate (default: current directory) ${kleur.bold("Options:")} --db-type network | file | folder | api (default: network) --quote " | \` (default: ") --with-ui Also scaffold a ui/ subworkspace using @tabularis/plugin-api --no-git Skip \`git init\` on the new project + --ci (migrate) Regenerate release.yml from the registry-ready template --dir Target directory (default: ./) -v, --version Print version -h, --help Print this help @@ -45,5 +84,7 @@ ${kleur.bold("Examples:")} npm create @tabularis/plugin@latest my-driver npm create @tabularis/plugin@latest sqlite-like -- --db-type=file npx @tabularis/create-plugin hackernews --db-type=api --with-ui + npx @tabularis/create-plugin migrate ./my-driver + npx @tabularis/create-plugin migrate ./my-driver --ci `); } diff --git a/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl b/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl index 97129022..c98923a5 100644 --- a/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl +++ b/packages/create-plugin/templates/rust-driver/.github/workflows/release.yml.tmpl @@ -5,9 +5,6 @@ on: tags: - "v*" -permissions: - contents: write - jobs: build: name: ${{ matrix.platform-label }} @@ -44,7 +41,7 @@ jobs: binary-suffix: ".exe" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -63,7 +60,7 @@ jobs: - name: Setup Node if: steps.ui.outputs.present == 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: "20" @@ -93,7 +90,10 @@ jobs: STAGE=staging mkdir -p "$STAGE" cp target/${{ matrix.target }}/release/${BIN_NAME}${{ matrix.binary-suffix }} "$STAGE/" - cp manifest.json "$STAGE/" + cp .tabularium "$STAGE/" + if [ -d assets ]; then + cp -r assets "$STAGE/" + fi if [ -f ui/dist/index.js ]; then mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" @@ -107,14 +107,44 @@ jobs: $stage = "staging" New-Item -ItemType Directory -Force -Path $stage | Out-Null Copy-Item "target\${{ matrix.target }}\release\${BIN_NAME}${{ matrix.binary-suffix }}" $stage - Copy-Item "manifest.json" $stage + Copy-Item ".tabularium" $stage + if (Test-Path "assets") { + Copy-Item -Recurse "assets" "$stage\assets" + } if (Test-Path "ui\dist\index.js") { New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } Compress-Archive -Path "$stage\*" -DestinationPath "${BIN_NAME}-${{ matrix.platform-label }}.zip" - - name: Upload release asset + - name: Stash artifact + uses: actions/upload-artifact@v5 + with: + name: ${BIN_NAME}-${{ matrix.platform-label }} + path: ${BIN_NAME}-${{ matrix.platform-label }}.zip + retention-days: 1 + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout (for the .tabularium manifest asset) + uses: actions/checkout@v5 + + - name: Download all build artifacts + uses: actions/download-artifact@v5 + with: + path: artifacts + merge-multiple: true + + - name: Publish release uses: softprops/action-gh-release@v2 with: - files: ${BIN_NAME}-${{ matrix.platform-label }}.zip + # .tabularium ships as a standalone asset: the registry resolves the + # manifest from release assets, not the git ref. + files: | + artifacts/*.zip + .tabularium diff --git a/packages/create-plugin/templates/rust-driver/manifest.json.tmpl b/packages/create-plugin/templates/rust-driver/.tabularium.tmpl similarity index 100% rename from packages/create-plugin/templates/rust-driver/manifest.json.tmpl rename to packages/create-plugin/templates/rust-driver/.tabularium.tmpl diff --git a/packages/create-plugin/templates/rust-driver/justfile.tmpl b/packages/create-plugin/templates/rust-driver/justfile.tmpl index e3c3f10f..7a1ddde3 100644 --- a/packages/create-plugin/templates/rust-driver/justfile.tmpl +++ b/packages/create-plugin/templates/rust-driver/justfile.tmpl @@ -61,7 +61,7 @@ build-ui: dev-install: build mkdir -p ~/.local/share/tabularis/plugins/${ID} cp target/debug/${BIN_NAME} ~/.local/share/tabularis/plugins/${ID}/ - cp manifest.json ~/.local/share/tabularis/plugins/${ID}/ + cp .tabularium ~/.local/share/tabularis/plugins/${ID}/ @if [ -f ui/dist/index.js ]; then \ mkdir -p ~/.local/share/tabularis/plugins/${ID}/ui/dist; \ cp ui/dist/index.js ~/.local/share/tabularis/plugins/${ID}/ui/dist/; \ @@ -73,7 +73,7 @@ dev-install: build dev-install: build mkdir -p "$HOME/Library/Application Support/tabularis/plugins/${ID}" cp target/debug/${BIN_NAME} "$HOME/Library/Application Support/tabularis/plugins/${ID}/" - cp manifest.json "$HOME/Library/Application Support/tabularis/plugins/${ID}/" + cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/${ID}/" @if [ -f ui/dist/index.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/${ID}/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/${ID}/ui/dist/"; \ @@ -86,7 +86,7 @@ dev-install: build $dest = Join-Path $env:APPDATA "tabularis\plugins\${ID}" New-Item -ItemType Directory -Force -Path $dest | Out-Null Copy-Item "target\debug\${BIN_NAME}.exe" $dest - Copy-Item "manifest.json" $dest + Copy-Item ".tabularium" $dest if (Test-Path "ui\dist\index.js") { New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist") diff --git a/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs b/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs index 5ced64ad..029d4742 100644 --- a/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs +++ b/packages/create-plugin/templates/rust-driver/src/handlers/crud.rs @@ -3,7 +3,7 @@ //! Implement these to enable inline row editing in the Tabularis data grid. //! Not implementing them means the grid is read-only for this driver, //! which is a valid stance — set `capabilities.readonly: true` in -//! manifest.json to hide the edit UI altogether. +//! the `.tabularium` manifest to hide the edit UI altogether. use serde_json::Value; diff --git a/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs b/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs index fdadb23f..6534b81d 100644 --- a/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs +++ b/packages/create-plugin/templates/rust-driver/src/handlers/metadata.rs @@ -14,7 +14,7 @@ pub fn get_databases(id: Value, _params: &Value) -> Value { } pub fn get_schemas(id: Value, _params: &Value) -> Value { - // Only meaningful if `capabilities.schemas` is true in manifest.json. + // Only meaningful if `capabilities.schemas` is true in the `.tabularium` manifest. ok_response(id, json!([])) } diff --git a/packages/create-plugin/tests/migrate.test.ts b/packages/create-plugin/tests/migrate.test.ts new file mode 100644 index 00000000..926fe752 --- /dev/null +++ b/packages/create-plugin/tests/migrate.test.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { migratePlugin } from "../src/migrate"; + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "ctp-migrate-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +function writeManifest(manifest: Record): void { + writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf8"); +} + +describe("migratePlugin", () => { + it("writes .tabularium, removes manifest.json, and reports both", () => { + writeManifest({ id: "my-driver", name: "My Driver", version: "1.2.3", description: "x" }); + + const result = migratePlugin(dir); + + expect(existsSync(join(dir, ".tabularium"))).toBe(true); + expect(existsSync(join(dir, "manifest.json"))).toBe(false); + expect(result.changed).toContain(".tabularium (written)"); + expect(result.changed).toContain("manifest.json (removed)"); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.version).toBe("1.2.3"); + }); + + it("keeps a load-bearing id that differs from name (with a warning)", () => { + writeManifest({ id: "my-driver", name: "My Driver", version: "1.0.0", description: "x" }); + + const result = migratePlugin(dir); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.id).toBe("my-driver"); + expect(result.warnings.join("\n")).toMatch(/Kept "id"/); + }); + + it("drops a redundant id that equals name", () => { + writeManifest({ id: "my-driver", name: "my-driver", version: "1.0.0", description: "x" }); + + migratePlugin(dir); + + const written = JSON.parse(readFileSync(join(dir, ".tabularium"), "utf8")); + expect(written.id).toBeUndefined(); + expect(written.name).toBe("my-driver"); + }); + + it("rewrites manifest.json references in workflow, justfile, and README", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + writeFileSync(join(dir, "justfile"), "cp manifest.json ~/plugins/\n", "utf8"); + // A schema link must NOT be rewritten — "manifest.json" is not a substring of it. + writeFileSync(join(dir, "README.md"), "See manifest.json and manifest.schema.json\n", "utf8"); + + migratePlugin(dir); + + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + expect(readFileSync(join(dir, "justfile"), "utf8")).toBe("cp .tabularium ~/plugins/\n"); + expect(readFileSync(join(dir, "README.md"), "utf8")).toBe("See .tabularium and manifest.schema.json\n"); + }); + + it("throws when version is missing or not semver", () => { + writeManifest({ name: "d", description: "x" }); + expect(() => migratePlugin(dir)).toThrow(/version/); + + writeManifest({ name: "d", version: "v1.0", description: "x" }); + expect(() => migratePlugin(dir)).toThrow(/version/); + }); + + it("throws on invalid JSON", () => { + writeFileSync(join(dir, "manifest.json"), "{ not json", "utf8"); + expect(() => migratePlugin(dir)).toThrow(/valid JSON/); + }); + + it("is a no-op when only .tabularium is present", () => { + writeFileSync(join(dir, ".tabularium"), "{}\n", "utf8"); + const result = migratePlugin(dir); + expect(result.changed).toHaveLength(0); + expect(result.warnings[0]).toMatch(/already present/); + }); + + it("throws when there is no manifest at all", () => { + expect(() => migratePlugin(dir)).toThrow(/No manifest.json/); + }); + + it("only renames the release.yml reference without --ci (CI structure untouched)", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x", executable: "d-plugin" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir); + + expect(result.ciRegenerated).toBe(false); + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + }); + + it("regenerates release.yml from the registry-ready template with --ci", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x", executable: "duckdb-plugin" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir, { ci: true }); + + expect(result.ciRegenerated).toBe(true); + const yml = readFileSync(join(dir, ".github/workflows/release.yml"), "utf8"); + // Registry-ready structure + standalone manifest asset. + expect(yml).toContain("actions/upload-artifact"); + expect(yml).toContain("Publish GitHub release"); + expect(yml).toMatch(/files: \|[\s\S]*\.tabularium/); + // BIN_NAME substituted from manifest.executable; no template placeholders left. + expect(yml).toContain("duckdb-plugin-${{ matrix.platform-label }}.zip"); + expect(yml).not.toContain("${BIN_NAME}"); + expect(yml).not.toContain(".tmpl"); + // Overwriting an existing workflow warns. + expect(result.warnings.join("\n")).toMatch(/Overwrote .*release\.yml/); + }); + + it("falls back to a reference rename when --ci has no executable to work with", () => { + writeManifest({ name: "d", version: "1.0.0", description: "x" }); + mkdirSync(join(dir, ".github/workflows"), { recursive: true }); + writeFileSync(join(dir, ".github/workflows/release.yml"), "cp manifest.json staging/\n", "utf8"); + + const result = migratePlugin(dir, { ci: true }); + + expect(result.ciRegenerated).toBe(false); + expect(result.warnings.join("\n")).toMatch(/--ci skipped/); + expect(readFileSync(join(dir, ".github/workflows/release.yml"), "utf8")).toBe("cp .tabularium staging/\n"); + }); +}); diff --git a/packages/plugin-api/src/hooks.ts b/packages/plugin-api/src/hooks.ts index 621b1d86..e6da0fa6 100644 --- a/packages/plugin-api/src/hooks.ts +++ b/packages/plugin-api/src/hooks.ts @@ -34,7 +34,7 @@ export function usePluginToast(): UsePluginToastReturn { /** * Read and write settings owned by a specific plugin. - * Pass your plugin id (the one declared in manifest.json). + * Pass your plugin id (the one declared in the `.tabularium` manifest). */ export function usePluginSetting(pluginId: string): UsePluginSettingReturn { return getHost().usePluginSetting(pluginId); diff --git a/packages/plugin-api/src/slots.ts b/packages/plugin-api/src/slots.ts index 7872da07..baf11117 100644 --- a/packages/plugin-api/src/slots.ts +++ b/packages/plugin-api/src/slots.ts @@ -113,7 +113,7 @@ export interface SlotComponentProps { } /** - * Manifest-level UI extension declaration (what authors put in manifest.json). + * Manifest-level UI extension declaration (what authors put in the `.tabularium` manifest). */ export interface UIExtensionDeclaration { slot: SlotName; diff --git a/plugins/tabularium-extensions.schema.json b/plugins/tabularium-extensions.schema.json new file mode 100644 index 00000000..8b6a9069 --- /dev/null +++ b/plugins/tabularium-extensions.schema.json @@ -0,0 +1,244 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://tabularis.dev/schemas/tabularium-extensions.json", + "title": "Tabularis extensions for the Tabularium manifest", + "description": "JSON-Schema fragment that registers Tabularis-specific fields on top of Tabularium's core manifest. Paste this into your Tabularium admin panel under /admin/manifest as the global extension. The registry merges it with the core schema and serves the result at /manifest.schema.json, so plugin authors get autocomplete for the whole manifest from a single URL.\n\nNaming rules enforced by Tabularium:\n * field names match /^[A-Za-z_][A-Za-z0-9_-]*$/\n * cannot shadow a core field (name, description, kind, tags, license, icon, screenshots, readme, readmes, documentation_url, homepage, support, min_runtime_version, category)\n * type whitelist: string | number | integer | boolean | array | object\n * no $ref, max depth 6, max 32 properties per object\n\nFor the runtime manifest.json that ships inside the plugin zip, see plugins/manifest.schema.json.", + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]*$", + "minLength": 1, + "maxLength": 64, + "description": "Stable driver identifier used as `ConnectionParams.driver` and as the on-disk plugin folder name (e.g. \"duckdb\", \"firestore\"). Must match the registry slug." + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(?:[-+].+)?$", + "minLength": 1, + "maxLength": 40, + "description": "Semver of this driver release. Should match the release tag the registry indexes (`v1.4.0` → `1.4.0`)." + }, + "executable": { + "type": "string", + "maxLength": 200, + "description": "Relative path to the plugin executable inside the ZIP (without `.exe`; Tabularis appends it on Windows)." + }, + "interpreter": { + "type": "string", + "maxLength": 100, + "description": "Interpreter for script-based plugins (e.g. `python3`, `node`). Omit for native binaries." + }, + "default_port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port pre-filled in the connection modal. Omit for file/folder-based drivers." + }, + "default_username": { + "type": "string", + "maxLength": 100, + "description": "Username pre-filled in the connection modal (e.g. `postgres`, `root`)." + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$", + "description": "Hex accent colour shown in the connection picker and sidebar (e.g. `#f97316`)." + }, + "lucide_icon": { + "type": "string", + "maxLength": 60, + "description": "Lucide icon name used inline beside the driver entry (e.g. `database`, `network`). Distinct from the core `icon` field, which is the catalogue logo URL." + }, + "capabilities": { + "type": "object", + "description": "Feature flags that decide which UI sections Tabularis shows for this driver. All flags are optional; absent flags default to `false` except `connection_string`, `alter_primary_key`, and `manage_tables` (default `true`).", + "properties": { + "schemas": { + "type": "boolean", + "description": "Database supports multiple named schemas (e.g. PostgreSQL `public`, MySQL databases). Shows the schema selector." + }, + "views": { + "type": "boolean", + "description": "Enable the Views section in the database explorer." + }, + "routines": { + "type": "boolean", + "description": "Stored procedures and functions surface in the explorer." + }, + "triggers": { + "type": "boolean", + "description": "Enable trigger listing and management." + }, + "file_based": { + "type": "boolean", + "description": "Connection points at a single file (e.g. SQLite). Replaces host/port with a file picker." + }, + "folder_based": { + "type": "boolean", + "description": "Connection points at a directory (e.g. CSV folder). Replaces host/port with a folder picker." + }, + "connection_string": { + "type": "boolean", + "description": "Show the connection-string import field on the connection modal. Default `true` for network drivers." + }, + "connection_string_example": { + "type": "string", + "maxLength": 200, + "description": "Placeholder shown inside the connection-string input (e.g. `postgres://user:pass@host:5432/db`)." + }, + "identifier_quote": { + "type": "string", + "maxLength": 2, + "description": "Character used to quote SQL identifiers — `\"` for ANSI, `` ` `` for MySQL-style." + }, + "alter_primary_key": { + "type": "boolean", + "description": "Driver supports altering primary keys on existing tables via ALTER TABLE." + }, + "alter_column": { + "type": "boolean", + "description": "Driver supports ALTER TABLE MODIFY/ALTER COLUMN on existing tables." + }, + "create_foreign_keys": { + "type": "boolean", + "description": "Driver enforces foreign key constraints." + }, + "manage_tables": { + "type": "boolean", + "description": "Driver supports CREATE/ALTER/DROP TABLE. Doesn't control index or FK operations. Default `true`." + }, + "auto_increment_keyword": { + "type": "string", + "maxLength": 40, + "description": "Keyword appended to the column definition for auto-increment columns (e.g. `AUTO_INCREMENT` for MySQL). Empty means the driver doesn't use a keyword." + }, + "serial_type": { + "type": "string", + "maxLength": 40, + "description": "Replacement data type for auto-increment columns (e.g. `SERIAL` for PostgreSQL). Empty means the driver doesn't use type substitution." + }, + "inline_pk": { + "type": "boolean", + "description": "Primary key is declared inline in the column definition (e.g. SQLite `AUTOINCREMENT`)." + }, + "no_connection_required": { + "type": "boolean", + "description": "API-based driver with no host/port/credentials. Hides the connection form entirely." + }, + "readonly": { + "type": "boolean", + "description": "Disables INSERT/UPDATE/DELETE in the UI. Also hides table/column management." + } + } + }, + "settings": { + "type": "array", + "maxItems": 32, + "description": "User-configurable settings rendered on Settings → Plugin → .", + "items": { + "type": "object", + "required": ["key", "label", "type"], + "properties": { + "key": { + "type": "string", + "pattern": "^[a-z_][a-z0-9_]*$", + "maxLength": 60, + "description": "Setting identifier; passed to the plugin process as `settings[key]`." + }, + "label": { + "type": "string", + "maxLength": 100, + "description": "Label shown next to the input in the settings UI." + }, + "type": { + "type": "string", + "maxLength": 20, + "description": "Input type. Tabularis renders `string`, `boolean`, `number`, `select`, `password`, `path` (and `folder` for folder pickers)." + }, + "default": { + "description": "Default value, any JSON type matching `type`." + }, + "description": { + "type": "string", + "maxLength": 280, + "description": "Helper text shown below the input." + }, + "required": { + "type": "boolean", + "description": "Block driver startup when the setting is empty." + }, + "options": { + "type": "array", + "maxItems": 32, + "description": "Enum values for `type: select`.", + "items": { + "type": "string", + "maxLength": 80 + } + } + } + } + }, + "data_types": { + "type": "array", + "maxItems": 32, + "description": "SQL data types this driver advertises in the column-creation UI.", + "items": { + "type": "object", + "required": ["name", "category", "requires_length", "requires_precision"], + "properties": { + "name": { + "type": "string", + "maxLength": 60, + "description": "Type name as it appears in DDL (e.g. `VARCHAR`, `BIGINT`)." + }, + "category": { + "type": "string", + "maxLength": 20, + "description": "UI grouping: `numeric`, `string`, `date`, `binary`, `json`, `spatial`, or `other`." + }, + "requires_length": { + "type": "boolean", + "description": "Type takes a length argument (e.g. `VARCHAR(255)`)." + }, + "requires_precision": { + "type": "boolean", + "description": "Type takes a precision/scale argument (e.g. `DECIMAL(10,2)`)." + }, + "default_length": { + "type": "string", + "maxLength": 20, + "description": "Optional default length value (e.g. `255` for `VARCHAR`)." + } + } + } + }, + "ui_extensions": { + "type": "array", + "maxItems": 32, + "description": "Slot-based UI injections. See plugins/PLUGIN_GUIDE.md §3b for available slot names.", + "items": { + "type": "object", + "required": ["slot", "module"], + "properties": { + "slot": { + "type": "string", + "maxLength": 80, + "description": "Target slot identifier (e.g. `row-edit-modal.field.after`)." + }, + "module": { + "type": "string", + "maxLength": 200, + "description": "Path to the JS module inside the plugin folder (e.g. `dist/index.js`)." + }, + "order": { + "type": "integer", + "minimum": 0, + "description": "Ordering weight when multiple plugins target the same slot. Lower runs first." + } + } + } + } + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a5a72f87..820a7fac 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -904,6 +904,26 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1459,6 +1479,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -1845,6 +1874,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2427,6 +2462,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2435,7 +2476,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2443,6 +2484,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -2603,6 +2649,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2642,7 +2689,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3876,6 +3923,17 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", +] + [[package]] name = "openssl" version = "0.10.75" @@ -3936,6 +3994,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4575,6 +4643,72 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "progenitor" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced2eadb9776a201d0585b4b072fd44d7d2104e0f3452d967b5a78966f4855cf" +dependencies = [ + "progenitor-client", + "progenitor-impl", + "progenitor-macro", +] + +[[package]] +name = "progenitor-client" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "296003fd74e64c77aeb2c10eae850eb543211a8557dd3b3de6f4230b5071e44b" +dependencies = [ + "bytes", + "futures-core", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b17e5363daa50bf1cccfade6b0fb970d2278758fd5cfa9ab69f25028e4b1afa3" +dependencies = [ + "heck 0.5.0", + "http", + "indexmap 2.13.0", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "typify", + "unicode-ident", +] + +[[package]] +name = "progenitor-macro" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4972aec926d1e06d6abc11ab3f063d2f7063be3dd46fd2839442c14d8e48f3ed" +dependencies = [ + "openapiv3", + "proc-macro2", + "progenitor-impl", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_tokenstream", + "serde_yaml", + "syn 2.0.117", +] + [[package]] name = "ptr_meta" version = "0.1.4" @@ -4924,6 +5058,16 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "regress" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" +dependencies = [ + "hashbrown 0.16.1", + "memchr", +] + [[package]] name = "rend" version = "0.4.2" @@ -4933,6 +5077,47 @@ dependencies = [ "bytecheck", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -4972,7 +5157,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -5157,6 +5342,16 @@ dependencies = [ "yasna", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.40.0" @@ -5324,6 +5519,7 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ + "chrono", "dyn-clone", "indexmap 1.9.3", "schemars_derive", @@ -5573,6 +5769,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6207,37 +6415,65 @@ dependencies = [ "notify", "once_cell", "openssl", - "reqwest", + "reqwest 0.13.2", "russh", "russh-keys", "rust_decimal", "rustls", "rustls-pemfile", "rustls-platform-verifier", + "semver", "serde", "serde_json", "serde_yaml", "sha2", "sqlx", "sysinfo", + "tabularium-sdk", "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-log", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tempfile", "tokio", "tokio-postgres", "tokio-postgres-rustls", "ulid", + "url", "urlencoding", "uuid", "zip", ] +[[package]] +name = "tabularium-sdk" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1295f2f940d4d9987af7161a68b4046df35a7876279d00b8612c7a56f0bbeb42" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-core", + "openapiv3", + "prettyplease", + "progenitor", + "progenitor-client", + "rand 0.8.5", + "regress", + "reqwest 0.12.28", + "serde", + "serde_json", + "syn 2.0.117", + "uuid", +] + [[package]] name = "tao" version = "0.34.5" @@ -6343,7 +6579,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -6459,6 +6695,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.6.0" @@ -6543,6 +6800,22 @@ dependencies = [ "zbus 5.14.0", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus 5.14.0", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.0" @@ -6559,7 +6832,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.2", "rustls", "semver", "serde", @@ -6788,6 +7061,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -7161,6 +7443,53 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "typify" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 2.0.117", + "thiserror 2.0.18", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 2.0.117", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.1.0" @@ -7549,6 +7878,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -7717,6 +8059,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -7882,20 +8233,7 @@ dependencies = [ "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-strings", ] [[package]] @@ -7977,13 +8315,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.6.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings", ] [[package]] @@ -8004,15 +8342,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-strings" version = "0.4.2" @@ -8022,15 +8351,6 @@ dependencies = [ "windows-link 0.1.3", ] -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - [[package]] name = "windows-sys" version = "0.45.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7f9285c8..16f1768f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,7 +55,12 @@ directories = "6.0.0" serde_yaml = "0.9.34" sysinfo = { version = "0.32", features = ["system"] } zip = "4.2.0" +tabularium-sdk = "0.2" +url = "2" +semver = "1" tauri-plugin-clipboard-manager = "2" +tauri-plugin-deep-link = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tokio-postgres = { version = "0.7.13", features = ["with-chrono-0_4", "with-uuid-1", "with-serde_json-1", "array-impls"] } deadpool-postgres = "0.14.1" # rustls is used for the PostgreSQL deadpool TLS path. native-tls (used by diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4deb0601..84ad5324 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "fs:scope-appdata-recursive", "opener:default", "clipboard-manager:allow-read-text", - "clipboard-manager:allow-write-text" + "clipboard-manager:allow-write-text", + "deep-link:default" ] } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d6d1f6c3..50f5735e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -42,7 +42,18 @@ pub struct AppConfig { pub copy_format: Option, pub csv_delimiter: Option, pub active_external_drivers: Option>, + /// COMPAT(registry-ga): legacy config key from before the Tabularium API + /// cutover. Read once by `compat::migrate_legacy_config`, then cleared. + #[serde(default)] pub custom_registry_url: Option, + /// COMPAT(registry-ga): override URL for the legacy static `registry.json` + /// merged into the catalogue during migration. Defaults to the built-in + /// GitHub-hosted file when unset. + #[serde(default)] + pub legacy_registry_url: Option, + /// Base URL of the Tabularium plugin registry (https://tabularium.wiki). + /// Defaults to the built-in instance when unset. + pub tabularium_registry_url: Option, pub plugins: Option>, pub editor_theme: Option, pub editor_font_family: Option, @@ -164,7 +175,8 @@ pub fn load_config_internal(app: &AppHandle) -> AppConfig let config_path = config_dir.join("config.json"); if config_path.exists() { if let Ok(content) = fs::read_to_string(config_path) { - if let Ok(config) = serde_json::from_str(&content) { + if let Ok(mut config) = serde_json::from_str::(&content) { + crate::plugins::compat::migrate_legacy_config(&mut config); // COMPAT(registry-ga) cache_config(&config); return config; } @@ -259,6 +271,9 @@ pub fn save_config(app: AppHandle, config: AppConfig) -> Result<(), String> { if config.active_external_drivers.is_some() { existing_config.active_external_drivers = config.active_external_drivers; } + if config.tabularium_registry_url.is_some() { + existing_config.tabularium_registry_url = config.tabularium_registry_url; + } if config.plugins.is_some() { existing_config.plugins = config.plugins; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 37309c80..e0175853 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -160,11 +160,29 @@ pub fn run() { sqlx::any::install_default_drivers(); tauri::Builder::default() + // Singleton: a second launch (typically a `tabularis://...` URL + // clicked while the app is already running) hands its argv to the + // first instance and exits. With `features = ["deep-link"]`, the + // plugin auto-routes those URLs through `on_open_url` — no manual + // argv parsing needed on our side; the empty callback exists only + // to satisfy the plugin signature. + // + // Order matters: must be the FIRST plugin in the chain so it can + // intercept duplicate launches before any heavy initialisation. + .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { + log::info!("Duplicate launch detected — forwarded to existing instance"); + if let Some(win) = tauri::Manager::get_webview_window(app, "main") { + let _ = win.unminimize(); + let _ = win.set_focus(); + } + })) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) + .manage(crate::plugins::deep_link::PendingInstall::default()) .manage(commands::QueryCancellationState::default()) .manage(export::ExportCancellationState::default()) .manage(dump_commands::DumpCancellationState::default()) @@ -207,6 +225,45 @@ pub fn run() { }); } + // Subscribe to `tabularis://` deep links so a registry's + // "Open in App" button can hand us a plugin slug + version. + // The handler emits a frontend event; the React side opens the + // install confirmation modal. See `plugins::deep_link`. + // + // On Linux/Windows the scheme is only auto-registered when the + // app is installed from a bundled package. Under `tauri dev` + // the binary lives in `target/debug/...`, so call + // `register("tabularis")` here — it writes the desktop / xdg-mime + // entry pointing at the current binary so Firefox & friends can + // route `tabularis://...` to us. The call is a no-op on macOS + // (handled by Info.plist) and idempotent across restarts. + { + use tauri_plugin_deep_link::DeepLinkExt; + let handle = app.handle().clone(); + let deep_link = app.deep_link(); + #[cfg(any(target_os = "linux", all(debug_assertions, target_os = "windows")))] + if let Err(e) = deep_link.register("tabularis") { + log::warn!("Failed to register tabularis:// scheme: {}", e); + } + deep_link.on_open_url({ + let handle = handle.clone(); + move |event| { + for url in event.urls() { + crate::plugins::deep_link::handle_url(&handle, url.as_str()); + } + } + }); + // Cold-start path: when the OS launched us *because of* a + // tabularis:// URL, on_open_url won't fire — the URL is + // already consumed by the launch handshake. Pull it out via + // get_current() and route it through the same handler. + if let Ok(Some(urls)) = deep_link.get_current() { + for url in urls { + crate::plugins::deep_link::handle_url(&handle, url.as_str()); + } + } + } + // Watch for pending MCP approval requests and run periodic cleanup. ai_approval_watcher::spawn(app.handle().clone()); @@ -449,6 +506,8 @@ pub fn run() { plugins::commands::get_plugin_manifest, plugins::commands::get_plugin_dir, plugins::commands::read_plugin_file, + plugins::commands::fetch_tabularium_plugin_preview, + plugins::deep_link::consume_pending_deep_link_install, plugins::manager::get_plugin_startup_errors, // JSON Viewer json_viewer::open_json_viewer_window, diff --git a/src-tauri/src/plugins/commands.rs b/src-tauri/src/plugins/commands.rs index f0bae479..d95e4c93 100644 --- a/src-tauri/src/plugins/commands.rs +++ b/src-tauri/src/plugins/commands.rs @@ -4,67 +4,152 @@ use std::time::Duration; use crate::drivers::driver_trait::PluginManifest; use crate::plugins::installer::{self, InstalledPluginInfo}; use crate::plugins::manager::ConfigManifest; -use crate::plugins::registry::{self, RegistryPluginWithStatus, RegistryReleaseWithStatus}; +use crate::plugins::registry::{self, RegistryPlugin, RegistryPluginWithStatus, RegistryReleaseWithStatus}; use tauri::AppHandle; use tokio::time::sleep; +/// Resolves which Tabularium registry to talk to. Operators pin a +/// URL via `tabularium_registry_url` in `config.json`; otherwise the +/// built-in default applies. +fn registry_base_url(config: &crate::config::AppConfig) -> &str { + config + .tabularium_registry_url + .as_deref() + .unwrap_or(registry::DEFAULT_TABULARIUM_URL) +} + #[tauri::command] pub async fn fetch_plugin_registry( app: AppHandle, ) -> Result, String> { let config = crate::config::load_config_internal(&app); - let remote = registry::fetch_registry(config.custom_registry_url.as_deref()).await?; + let base_url = registry_base_url(&config).trim_end_matches('/').to_string(); + // COMPAT(registry-ga): merge the API with the legacy static registry.json so + // not-yet-migrated plugins stay visible during the transition. + let legacy_url = crate::plugins::compat::legacy_registry_url(&config); + let remote = crate::plugins::compat::resolve_registry(&base_url, &legacy_url).await?; let installed = installer::list_installed()?; let platform = registry::get_current_platform(); - let result = remote + let result: Vec = remote .plugins .into_iter() - .map(|plugin| { + .map(|mut plugin| { let installed_version = installed .iter() .find(|i| i.id == plugin.id) .map(|i| i.version.clone()); + // Only a real Tabularium API serves plugin detail pages. A legacy + // `.json` base would make the frontend build a broken + // `…/registry.json/plugins/` link, so leave it unset there. + if !base_url.ends_with(".json") { + plugin.registry_base_url = Some(base_url.clone()); + } + to_plugin_with_status(plugin, installed_version, &platform) + }) + .collect(); - let update_available = installed_version - .as_ref() - .map(|iv| iv != &plugin.latest_version) - .unwrap_or(false); - - let releases: Vec = plugin - .releases - .iter() - .map(|r| { - let platform_supported = - r.assets.contains_key(&platform) || r.assets.contains_key("universal"); - RegistryReleaseWithStatus { - version: r.version.clone(), - min_tabularis_version: r.min_tabularis_version.clone(), - platform_supported, - } - }) - .collect(); + Ok(result) +} - let platform_supported = releases - .iter() - .any(|r| r.version == plugin.latest_version && r.platform_supported); - - RegistryPluginWithStatus { - id: plugin.id, - name: plugin.name, - description: plugin.description, - author: plugin.author, - homepage: plugin.homepage, - latest_version: plugin.latest_version, - releases, - installed_version, - update_available, +/// Builds a `RegistryPluginWithStatus` from a registry plugin + the installed +/// version (if any), computing per-release platform support and a SemVer-aware +/// `update_available`. `install_action` is left `None` here; the deeplink +/// preview path sets it explicitly. +fn to_plugin_with_status( + plugin: RegistryPlugin, + installed_version: Option, + platform: &str, +) -> RegistryPluginWithStatus { + let releases: Vec = plugin + .releases + .iter() + .map(|r| { + let platform_supported = + r.assets.contains_key(platform) || r.assets.contains_key("universal"); + RegistryReleaseWithStatus { + version: r.version.clone(), + min_tabularis_version: r.min_tabularis_version.clone(), platform_supported, } }) .collect(); - Ok(result) + let platform_supported = releases + .iter() + .any(|r| r.version == plugin.latest_version && r.platform_supported); + + // SemVer-aware: "update" classification doubles as `update_available`. + let action = registry::classify_install(installed_version.as_deref(), &plugin.latest_version); + let update_available = matches!(action, registry::InstallAction::Update); + + RegistryPluginWithStatus { + id: plugin.id, + name: plugin.name, + description: plugin.description, + author: plugin.author, + homepage: plugin.homepage, + latest_version: plugin.latest_version, + releases, + installed_version, + update_available, + platform_supported, + icon: plugin.icon, + repo_url: plugin.repo_url, + kind: plugin.kind, + tags: plugin.tags, + category: plugin.category, + downloads: plugin.downloads, + registry_base_url: plugin.registry_base_url, + engine: plugin.engine, + paradigms: plugin.paradigms, + verified: plugin.verified, + install_action: None, + } +} + +/// API-path install resolution: resolves the concrete target version, confirms +/// the platform is supported (+ sha256), and returns the registry's TRACKED +/// download URL. Returns `(download_url, expected_sha256, target_version)`. +async fn resolve_api_install_asset( + base: &str, + plugin_id: &str, + version: Option<&str>, + platform: &str, +) -> Result<(String, Option, String), String> { + let target_version = match version { + Some(v) => v.to_string(), + None => { + let detail = crate::plugins::tabularium::fetch_plugin_detail(base, plugin_id).await?; + if !detail.latest_version.is_empty() { + detail.latest_version + } else { + detail + .releases + .first() + .map(|r| r.version.clone()) + .ok_or_else(|| { + format!("Plugin '{}' has no releases on the registry", plugin_id) + })? + } + } + }; + // Resolve the asset for its sha256 + to confirm the platform is supported, + // but download via the registry's TRACKED redirect so the download counter + // increments. Use the dedicated `/latest` endpoint when no version was + // pinned, otherwise the versioned `/releases/{version}` endpoint. + let asset = + registry::resolve_tabularium_asset(base, plugin_id, &target_version, platform).await?; + let download_url = match version { + Some(_) => crate::plugins::tabularium::tracked_download_url( + base, + plugin_id, + &target_version, + platform, + ), + None => crate::plugins::tabularium::tracked_latest_download_url(base, plugin_id, platform), + }; + Ok((download_url, asset.expected_sha256, target_version)) } #[tauri::command] @@ -80,36 +165,49 @@ pub async fn install_plugin( sleep(Duration::from_millis(500)).await; let config = crate::config::load_config_internal(&app); - let remote = registry::fetch_registry(config.custom_registry_url.as_deref()).await?; let platform = registry::get_current_platform(); + let base = registry_base_url(&config); - let plugin = remote - .plugins - .iter() - .find(|p| p.id == plugin_id) - .ok_or_else(|| format!("Plugin '{}' not found in registry", plugin_id))?; - - let target_version = version.as_deref().unwrap_or(&plugin.latest_version); - - let release = plugin - .releases - .iter() - .find(|r| r.version == target_version) - .ok_or_else(|| format!("No release found for version {}", target_version))?; - - let download_url = release - .assets - .get(&platform) - .or_else(|| release.assets.get("universal")) - .ok_or_else(|| { - format!( - "Plugin '{}' does not support platform '{}'", - plugin_id, platform - ) - })?; - - installer::download_and_install(&plugin_id, download_url).await?; + // Resolve the download URL + expected SHA-256 + concrete target version. + // Resolution order: + // 1. COMPAT(registry-ga): if the configured registry URL is a static + // `.json` file, resolve directly from it (direct download, no sha256). + // 2. Otherwise use the Tabularium API + the TRACKED redirect download. + // 3. COMPAT(registry-ga): if the API doesn't know the plugin (it lives + // only in the legacy registry, not yet migrated), fall back to the + // configured legacy `registry.json`'s direct asset. + let (download_url, expected_sha256, target_version) = + if let Some(res) = + crate::plugins::compat::resolve_static_asset(base, &plugin_id, version.as_deref(), &platform) + .await + { + let asset = res?; + (asset.download_url, asset.expected_sha256, asset.version) + } else { + match resolve_api_install_asset(base, &plugin_id, version.as_deref(), &platform).await { + Ok(resolved) => resolved, + Err(api_err) => { + // COMPAT(registry-ga): legacy-registry install fallback. + let legacy_url = crate::plugins::compat::legacy_registry_url(&config); + match crate::plugins::compat::fetch_static_asset( + &legacy_url, + &plugin_id, + version.as_deref(), + &platform, + ) + .await + { + Ok(asset) => (asset.download_url, asset.expected_sha256, asset.version), + Err(_) => return Err(api_err), + } + } + } + }; + installer::download_and_install(&plugin_id, &download_url, expected_sha256.as_deref()).await?; + // Verify the installed manifest matches what the registry advertised. The + // canonical schema uses `name` as the identity, so `installed_plugin.id` + // falls back to the manifest `name` when no legacy `id` is present. let installed_plugin = installer::read_installed_plugin(&plugin_id)?; if installed_plugin.id != plugin_id { return Err(format!( @@ -180,21 +278,18 @@ pub async fn enable_plugin(app: AppHandle, plugin_id: String) -> Result<(), Stri Ok(()) } -/// Reads a plugin's manifest.json from disk and returns a PluginManifest. +/// Reads a plugin's `.tabularium` manifest from disk and returns a PluginManifest. /// Useful for retrieving setting definitions for disabled plugins. #[tauri::command] pub async fn get_plugin_manifest(plugin_id: String) -> Result { let plugins_dir = installer::get_plugins_dir()?; - let manifest_path = plugins_dir.join(&plugin_id).join("manifest.json"); + let plugin_dir = plugins_dir.join(&plugin_id); - let manifest_str = fs::read_to_string(&manifest_path) + let config: ConfigManifest = installer::read_manifest(&plugin_dir) .map_err(|e| format!("Failed to read manifest for '{}': {}", plugin_id, e))?; - let config: ConfigManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse manifest for '{}': {}", plugin_id, e))?; - Ok(PluginManifest { - id: config.id, + id: config.id.unwrap_or_else(|| config.name.clone()), name: config.name, version: config.version, description: config.description, @@ -223,6 +318,53 @@ pub fn get_plugin_dir(plugin_id: String) -> Result { .map(|s| s.to_string()) } +/// Fetches a rich plugin preview from a Tabularium registry, used by the +/// `tabularis://` deep-link confirmation modal. When `registry_url` is +/// omitted the user's configured registry (or the built-in default) is +/// queried instead. Returns `RegistryPluginWithStatus` populated with the +/// installed version and a resolved `install_action` so the modal can show +/// Install / Update / "already installed". +/// +/// NB: the deeplink *preview* talks to the Tabularium API directly (no static +/// `registry.json` support). This is intentional — `tabularis://` links target +/// the new registry — so there is deliberately no `COMPAT(registry-ga)` marker +/// here. (`install_plugin` itself DOES support static registries, so the +/// catalogue install path stays fully backwards-compatible.) +#[tauri::command] +pub async fn fetch_tabularium_plugin_preview( + app: AppHandle, + slug: String, + registry_url: Option, + version: Option, +) -> Result { + let config = crate::config::load_config_internal(&app); + let base = registry_url + .as_deref() + .map(str::to_string) + .unwrap_or_else(|| registry_base_url(&config).to_string()); + let mut plugin = crate::plugins::tabularium::fetch_plugin_detail(&base, &slug).await?; + plugin.registry_base_url = Some(base.trim_end_matches('/').to_string()); + + let installed_version = installer::list_installed()? + .into_iter() + .find(|i| i.id == slug) + .map(|i| i.version); + let platform = registry::get_current_platform(); + + // Target = the version the deeplink will install: the pinned version if the + // link specified one, otherwise the registry's latest. + let target = version + .as_deref() + .filter(|v| !v.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.latest_version.clone()); + let action = registry::classify_install(installed_version.as_deref(), &target); + + let mut with_status = to_plugin_with_status(plugin, installed_version, &platform); + with_status.install_action = Some(action); + Ok(with_status) +} + /// Reads a file from an installed plugin's directory. /// The `file_path` must be a relative path with no `..` components. #[tauri::command] diff --git a/src-tauri/src/plugins/compat.rs b/src-tauri/src/plugins/compat.rs new file mode 100644 index 00000000..75162fca --- /dev/null +++ b/src-tauri/src/plugins/compat.rs @@ -0,0 +1,384 @@ +//! BACKWARDS-COMPAT LAYER — remove after the Tabularium registry GA once all +//! published plugins have migrated to the new manifest/registry format. +//! +//! Everything legacy lives here so removal is mechanical: +//! 1. Delete this file. +//! 2. `grep -rn "COMPAT(registry-ga)"` and revert each marked call site +//! (config.rs, commands.rs, installer.rs). +//! 3. Remove `pub mod compat;` from plugins/mod.rs. +//! +//! See docs/superpowers/specs/2026-06-06-deeplink-versioning-and-bc-layer-design.md +//! +//! Covers four legacy paths: +//! 1. Config key alias `custom_registry_url` -> `tabularium_registry_url` +//! 2. Static flat `registry.json` fetcher (pre-API registries) +//! 3. Legacy GitHub-raw default URL fallback +//! 4. Legacy `manifest.json` bundle manifest + +use std::path::Path; + +use crate::plugins::registry::{self, PluginRegistry}; + +/// Legacy default registry: the flat `registry.json` hosted on GitHub that +/// `main` shipped before the Tabularium API cutover. Used only as a +/// last-resort fallback (see `resolve_registry`). +pub const LEGACY_REGISTRY_URL: &str = + "https://raw.githubusercontent.com/TabularisDB/tabularis/main/plugins/registry.json"; + +/// Fetches and parses a legacy flat-JSON registry from `url`. The +/// `PluginRegistry` struct still deserializes the old schema unchanged +/// (new fields are `#[serde(default)]`). +pub async fn fetch_legacy_registry(url: &str) -> Result { + let response = reqwest::get(url) + .await + .map_err(|e| format!("Failed to fetch legacy plugin registry: {}", e))?; + if !response.status().is_success() { + return Err(format!( + "Legacy registry at {} returned HTTP {}", + url, + response.status() + )); + } + response + .json::() + .await + .map_err(|e| format!("Failed to parse legacy plugin registry: {}", e)) +} + +/// COMPAT(registry-ga): registry-fetch entry point that MERGES the Tabularium +/// API with the legacy static `registry.json`, so plugins not yet migrated to +/// the API stay visible during the transition. +/// +/// Order: +/// 1. If `base_url` ends in `.json`, that file IS the source — return it +/// verbatim (no API, no merge). +/// 2. Otherwise fetch the API and the legacy registry, then UNION them with +/// `merge_registries` (API entry wins on id conflict). +/// 3. If only one side is reachable, use it (API down → legacy only; legacy +/// unreachable → API only). +/// 4. If both fail, surface the ORIGINAL API error. +pub async fn resolve_registry(base_url: &str, legacy_url: &str) -> Result { + // Explicit static registry — the configured file is the sole source. + if base_url.ends_with(".json") { + return fetch_legacy_registry(base_url).await; + } + + let api = registry::fetch_tabularium_registry(base_url).await; + let legacy = fetch_legacy_registry(legacy_url).await; + + match (api, legacy) { + (Ok(api), Ok(legacy)) => Ok(merge_registries(api, legacy)), + (Ok(api), Err(e)) => { + log::warn!("Legacy registry merge skipped ({}): {}", legacy_url, e); + Ok(api) + } + (Err(e), Ok(legacy)) => { + log::warn!( + "Tabularium API failed ({}): {} — using legacy registry only", + base_url, + e + ); + Ok(legacy) + } + (Err(api_err), Err(_)) => Err(api_err), + } +} + +/// Union two registries, preferring `api` entries on id conflict. Plugins that +/// exist only in `legacy` (not yet migrated to the API) are appended. +fn merge_registries(api: PluginRegistry, legacy: PluginRegistry) -> PluginRegistry { + use std::collections::HashSet; + let seen: HashSet<&str> = api.plugins.iter().map(|p| p.id.as_str()).collect(); + let extra: Vec<_> = legacy + .plugins + .into_iter() + .filter(|p| !seen.contains(p.id.as_str())) + .collect(); + let mut plugins = api.plugins; + plugins.extend(extra); + PluginRegistry { + schema_version: 1, + plugins, + } +} + +/// COMPAT(registry-ga): URL of the legacy static `registry.json` to merge with +/// the API. Configurable via `legacy_registry_url`; defaults to the built-in +/// GitHub-hosted file. +pub fn legacy_registry_url(config: &crate::config::AppConfig) -> String { + config + .legacy_registry_url + .clone() + .unwrap_or_else(|| LEGACY_REGISTRY_URL.to_string()) +} + +/// COMPAT(registry-ga): reads a legacy `manifest.json` bundle manifest for +/// plugins published before the `.tabularium` cutover. Same JSON shape — the +/// canonical structs tolerate the old flat fields via `#[serde(default)]`. +pub fn read_legacy_manifest(dir: &Path) -> Option> { + let path = dir.join("manifest.json"); + if !path.exists() { + return None; + } + let read = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read legacy manifest {:?}: {}", path, e)) + .and_then(|s| { + serde_json::from_str::(&s) + .map_err(|e| format!("Failed to parse legacy manifest {:?}: {}", path, e)) + }); + Some(read) +} + +/// Folds a legacy `custom_registry_url` into `tabularium_registry_url` when the +/// new key is unset, then clears the legacy field so it never round-trips back +/// to disk. The new key always wins if both are present. +pub fn migrate_legacy_config(config: &mut crate::config::AppConfig) { + if let Some(legacy) = config.custom_registry_url.take() { + if config.tabularium_registry_url.is_none() { + config.tabularium_registry_url = Some(legacy); + } + } +} + +/// COMPAT(registry-ga): a plugin asset resolved from a static flat `registry.json`. +/// Static registries carry no per-asset sha256 and have no tracked-download +/// endpoint, so `download_url` is the direct asset URL (matching the pre-API +/// install path) and `expected_sha256` is always `None`. +pub struct StaticAsset { + pub download_url: String, + pub expected_sha256: Option, + pub version: String, +} + +/// COMPAT(registry-ga): resolve a plugin's download asset from a static flat +/// `registry.json` (configured registry URL ending in `.json`). Returns `None` +/// when `base_url` is not a static registry, signalling the caller to use the +/// Tabularium API path instead. +pub async fn resolve_static_asset( + base_url: &str, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Option> { + if !base_url.ends_with(".json") { + return None; + } + Some(fetch_static_asset(base_url, slug, version, platform).await) +} + +/// COMPAT(registry-ga): fetch + resolve a plugin asset from a specific static +/// `registry.json` URL. Used as the install fallback for plugins that live only +/// in the legacy registry (not yet migrated to the API), regardless of the +/// configured registry's URL shape. +pub async fn fetch_static_asset( + url: &str, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Result { + let registry = fetch_legacy_registry(url).await?; + pick_static_asset(®istry, slug, version, platform) +} + +/// Pure asset picker over an already-fetched static registry — mirrors the +/// pre-API install resolution: find the plugin, pick the requested release +/// (or latest), then the platform asset or the `universal` fallback. +fn pick_static_asset( + registry: &PluginRegistry, + slug: &str, + version: Option<&str>, + platform: &str, +) -> Result { + let plugin = registry + .plugins + .iter() + .find(|p| p.id == slug) + .ok_or_else(|| format!("Plugin '{}' not found in registry", slug))?; + let target_version = version + .map(str::to_string) + .unwrap_or_else(|| plugin.latest_version.clone()); + let release = plugin + .releases + .iter() + .find(|r| r.version == target_version) + .ok_or_else(|| format!("No release '{}' for plugin '{}'", target_version, slug))?; + let download_url = release + .assets + .get(platform) + .or_else(|| release.assets.get("universal")) + .cloned() + .ok_or_else(|| format!("Plugin '{}' does not support platform '{}'", slug, platform))?; + Ok(StaticAsset { + download_url, + expected_sha256: None, + version: target_version, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use crate::plugins::registry::{PluginRegistry, PluginRelease, RegistryPlugin}; + use std::collections::HashMap; + + fn static_registry() -> PluginRegistry { + let mut assets = HashMap::new(); + assets.insert( + "linux-x64".to_string(), + "https://host/firestore-0.5.0-linux-x64.zip".to_string(), + ); + assets.insert( + "universal".to_string(), + "https://host/firestore-0.5.0-universal.zip".to_string(), + ); + PluginRegistry { + schema_version: 1, + plugins: vec![RegistryPlugin { + id: "firestore".to_string(), + latest_version: "0.5.0".to_string(), + releases: vec![PluginRelease { + version: "0.5.0".to_string(), + min_tabularis_version: None, + assets, + }], + ..Default::default() + }], + } + } + + #[test] + fn static_asset_picks_platform_then_latest() { + let reg = static_registry(); + let a = pick_static_asset(®, "firestore", None, "linux-x64").expect("resolves"); + assert_eq!(a.download_url, "https://host/firestore-0.5.0-linux-x64.zip"); + assert_eq!(a.version, "0.5.0"); // None → latest + assert!(a.expected_sha256.is_none()); // static registries carry no sha + } + + #[test] + fn static_asset_falls_back_to_universal() { + let reg = static_registry(); + let a = pick_static_asset(®, "firestore", Some("0.5.0"), "win-x64").expect("universal"); + assert_eq!(a.download_url, "https://host/firestore-0.5.0-universal.zip"); + } + + #[test] + fn static_asset_errors_on_unknown_plugin_release_or_platform() { + let reg = static_registry(); + assert!(pick_static_asset(®, "nope", None, "linux-x64").is_err()); + assert!(pick_static_asset(®, "firestore", Some("9.9.9"), "linux-x64").is_err()); + let mut reg2 = static_registry(); + reg2.plugins[0].releases[0].assets.remove("universal"); + reg2.plugins[0].releases[0].assets.remove("linux-x64"); + reg2.plugins[0].releases[0] + .assets + .insert("darwin-arm64".to_string(), "x".to_string()); + assert!(pick_static_asset(®2, "firestore", None, "linux-x64").is_err()); + } + + #[tokio::test] + async fn resolve_static_asset_returns_none_for_non_json_base() { + // Non-.json base must defer to the API path (None), no network hit. + assert!(resolve_static_asset("https://registry.tabularis.dev", "firestore", None, "linux-x64") + .await + .is_none()); + } + + fn registry_with_ids(ids: &[&str]) -> PluginRegistry { + PluginRegistry { + schema_version: 1, + plugins: ids + .iter() + .map(|id| RegistryPlugin { + id: (*id).to_string(), + latest_version: "1.0.0".to_string(), + ..Default::default() + }) + .collect(), + } + } + + #[test] + fn merge_unions_legacy_only_plugins_and_api_wins_on_conflict() { + let api = PluginRegistry { + schema_version: 1, + plugins: vec![RegistryPlugin { + id: "firestore".to_string(), + latest_version: "0.5.0".to_string(), // API version + ..Default::default() + }], + }; + let legacy = registry_with_ids(&["firestore", "duckdb", "csv"]); // legacy firestore is 1.0.0 + let merged = merge_registries(api, legacy); + let ids: Vec<&str> = merged.plugins.iter().map(|p| p.id.as_str()).collect(); + assert_eq!(ids, vec!["firestore", "duckdb", "csv"]); // API firestore first, legacy-only appended + let fs = merged.plugins.iter().find(|p| p.id == "firestore").unwrap(); + assert_eq!(fs.latest_version, "0.5.0", "API entry must win on id conflict"); + } + + #[test] + fn legacy_registry_url_defaults_then_honours_config() { + let mut cfg = AppConfig::default(); + assert_eq!(legacy_registry_url(&cfg), LEGACY_REGISTRY_URL); + cfg.legacy_registry_url = Some("https://self.host/registry.json".into()); + assert_eq!(legacy_registry_url(&cfg), "https://self.host/registry.json"); + } + + #[test] + fn migrates_legacy_registry_key_when_new_unset() { + let mut cfg = AppConfig { + custom_registry_url: Some("https://old.example/registry.json".into()), + tabularium_registry_url: None, + ..Default::default() + }; + migrate_legacy_config(&mut cfg); + assert_eq!( + cfg.tabularium_registry_url.as_deref(), + Some("https://old.example/registry.json") + ); + assert!(cfg.custom_registry_url.is_none(), "legacy key must be cleared"); + } + + #[test] + fn new_key_wins_over_legacy() { + let mut cfg = AppConfig { + custom_registry_url: Some("https://old.example".into()), + tabularium_registry_url: Some("https://new.example".into()), + ..Default::default() + }; + migrate_legacy_config(&mut cfg); + assert_eq!(cfg.tabularium_registry_url.as_deref(), Some("https://new.example")); + assert!(cfg.custom_registry_url.is_none()); + } + + #[tokio::test] + async fn json_suffix_url_takes_legacy_path() { + // A bogus .json URL must fail via the legacy fetcher (network error), + // proving it never went through the Tabularium SDK path. + let err = resolve_registry("http://127.0.0.1:0/registry.json", LEGACY_REGISTRY_URL) + .await + .unwrap_err(); + assert!( + err.contains("legacy plugin registry"), + "expected legacy-path error, got: {err}" + ); + } + + #[test] + fn reads_legacy_manifest_json() { + use crate::plugins::manager::ConfigManifest; + let dir = std::env::temp_dir().join("tab-compat-test-manifest"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("manifest.json"), + r#"{"name":"legacy","version":"1.0.0","description":"old"}"#, + ) + .unwrap(); + let parsed: ConfigManifest = read_legacy_manifest(&dir).unwrap().unwrap(); + assert_eq!(parsed.name, "legacy"); + assert_eq!(parsed.version, "1.0.0"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/src-tauri/src/plugins/deep_link.rs b/src-tauri/src/plugins/deep_link.rs new file mode 100644 index 00000000..f2510c5a --- /dev/null +++ b/src-tauri/src/plugins/deep_link.rs @@ -0,0 +1,236 @@ +//! `tabularis://` deep-link handling. +//! +//! When a Tabularium registry renders an "Open in App" button (see its +//! `GET /api/instance/info` `appUrlSchemes` payload), the registry mints a +//! URL of the form: +//! +//! ```text +//! tabularis://install/?version=®istry= +//! ``` +//! +//! * `` — required path segment, the plugin id on the registry. +//! * `version` — optional, pins a release; absent ⇒ latest. +//! * `registry` — optional, full base URL of the registry that minted +//! the link. Lets a user installed against registry A +//! follow a link from registry B; the frontend should +//! prompt before switching the configured registry. +//! +//! The Tauri layer parses incoming URLs into a [`PluginInstallRequest`] +//! and emits it on the `tabularis://plugin-install` event so the React +//! frontend can show the install confirmation modal. Anything else (an +//! unknown action, a malformed slug) is logged and dropped — we never +//! auto-install without a user click. + +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Emitter, Manager}; +use url::Url; + +/// Tauri-managed state holding the most recent install request that has not +/// yet been picked up by the frontend. Buffered separately from the emitted +/// event so we don't drop URLs that arrive before the React listener mounts +/// (typical for cold-start launches). +#[derive(Default)] +pub struct PendingInstall(pub Mutex>); + +/// Event name the frontend listens on. Kept stable as part of the +/// public contract with the Tabularium registry. +pub const PLUGIN_INSTALL_EVENT: &str = "tabularis://plugin-install"; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PluginInstallRequest { + /// Plugin slug on the registry (matches the manifest `id`). + pub slug: String, + /// Optional pinned version. `None` ⇒ install latest. + pub version: Option, + /// Optional source registry. `None` ⇒ use whichever registry the + /// user currently has configured. + pub registry: Option, +} + +/// Parses a `tabularis://...` URL into a typed install request. +/// Returns `None` for URLs we don't (yet) handle so callers can ignore +/// them quietly. +/// +/// Accepted forms (Tabularium currently emits the query-based one): +/// * `tabularis://install?slug=&version=®istry=` +/// * `tabularis://install/?version=®istry=` +/// * `tabularis:install/` (some launchers strip the `//`) +pub fn parse_install_url(raw: &str) -> Option { + let url = Url::parse(raw).ok()?; + if url.scheme() != "tabularis" { + return None; + } + + // Action = host if present (`tabularis://install...`), otherwise the + // first path segment (`tabularis:install/...`). + let host = url.host_str(); + let action_from_path = || { + url.path() + .trim_start_matches('/') + .split('/') + .next() + .unwrap_or("") + .to_string() + }; + let action = host.map(str::to_string).unwrap_or_else(action_from_path); + if action != "install" { + log::debug!("Ignoring tabularis:// URL with unknown action: {}", raw); + return None; + } + + // Collect query first — Tabularium's "Open in App" link puts slug there. + let mut version = None; + let mut registry = None; + let mut query_slug: Option = None; + for (k, v) in url.query_pairs() { + match k.as_ref() { + "slug" => query_slug = Some(v.into_owned()), + "version" => version = Some(v.into_owned()), + "registry" => registry = Some(v.into_owned()), + _ => {} + } + } + + // Fall back to path segments after the action if no `?slug=` was given. + let slug = query_slug.or_else(|| { + let mut segments = url + .path_segments() + .map(|s| s.collect::>()) + .unwrap_or_default(); + if host.is_none() && segments.first().copied() == Some("install") { + segments.remove(0); + } + segments.into_iter().next().map(|s| s.to_string()) + })?; + if !is_valid_slug(&slug) { + log::warn!("Rejected tabularis:// URL — bad slug: {:?}", slug); + return None; + } + + Some(PluginInstallRequest { + slug, + version, + registry, + }) +} + +/// Slug must match Tabularium's own pattern (`^[a-z0-9][a-z0-9-]*$`, +/// length 1–64). Stops path-traversal-ish input from reaching the installer +/// without forcing assumptions about the registry's host (self-hosting must +/// keep working without an allowlist). +fn is_valid_slug(s: &str) -> bool { + let bytes = s.as_bytes(); + if bytes.is_empty() || bytes.len() > 64 { + return false; + } + let valid_char = |b: &u8| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-'; + bytes.iter().all(valid_char) && bytes.first().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) +} + +/// Dispatch a single URL to the frontend. Called from: +/// * the deep-link plugin's `on_open_url` handler (warm handoff), +/// * the cold-start `get_current()` path on app boot, +/// * the `single_instance` callback when a second launch forwards args. +/// +/// We both **emit** an event (so an already-mounted listener reacts +/// immediately) AND **stash** the request in `PendingInstall` so a fresh +/// React subscription can drain it on mount. Without the stash a cold-start +/// URL is delivered before the webview's event listener exists and silently +/// dropped. +pub fn handle_url(app: &AppHandle, raw: &str) { + let Some(req) = parse_install_url(raw) else { + log::info!("Ignoring tabularis:// URL: {}", raw); + return; + }; + + // Stash for cold-start replay; the frontend pulls this on mount via + // `consume_pending_deep_link_install`. + if let Some(state) = app.try_state::() { + if let Ok(mut slot) = state.0.lock() { + *slot = Some(req.clone()); + } + } + + if let Err(err) = app.emit(PLUGIN_INSTALL_EVENT, &req) { + log::error!( + "Failed to emit {}: {} (request: {:?})", + PLUGIN_INSTALL_EVENT, + err, + req + ); + } else { + log::info!("Emitted {} for slug '{}'", PLUGIN_INSTALL_EVENT, req.slug); + } + + // Focus the main window so the user sees the confirmation modal — + // a second launch may otherwise leave Tabularis in the background. + if let Some(win) = app.get_webview_window("main") { + let _ = win.unminimize(); + let _ = win.set_focus(); + } +} + +/// Drains the pending deep-link install request, if any. Called from the +/// frontend immediately after the listener mounts to recover URLs that +/// arrived during cold-start before the event subscription existed. +#[tauri::command] +pub fn consume_pending_deep_link_install( + state: tauri::State<'_, PendingInstall>, +) -> Option { + state.0.lock().ok().and_then(|mut slot| slot.take()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_canonical_install_url() { + let req = + parse_install_url("tabularis://install/duckdb?version=0.2.0®istry=https%3A%2F%2Fr.example") + .expect("valid URL"); + assert_eq!(req.slug, "duckdb"); + assert_eq!(req.version.as_deref(), Some("0.2.0")); + assert_eq!(req.registry.as_deref(), Some("https://r.example")); + } + + #[test] + fn parses_query_based_install_url() { + // Format emitted by the Tabularium frontend's "Open in App" button. + let req = parse_install_url( + "tabularis://install?registry=https%3A%2F%2Fregistry.spitzli.dev&slug=firestore-tabularis&version=0.2.0", + ) + .expect("valid URL"); + assert_eq!(req.slug, "firestore-tabularis"); + assert_eq!(req.version.as_deref(), Some("0.2.0")); + assert_eq!(req.registry.as_deref(), Some("https://registry.spitzli.dev")); + } + + #[test] + fn parses_minimal_install_url() { + let req = parse_install_url("tabularis://install/csv").expect("valid URL"); + assert_eq!(req.slug, "csv"); + assert!(req.version.is_none()); + assert!(req.registry.is_none()); + } + + #[test] + fn rejects_other_schemes() { + assert!(parse_install_url("https://example.com").is_none()); + assert!(parse_install_url("tabularium://install/foo").is_none()); + } + + #[test] + fn rejects_unknown_actions() { + assert!(parse_install_url("tabularis://browse/foo").is_none()); + } + + #[test] + fn rejects_missing_slug() { + assert!(parse_install_url("tabularis://install/").is_none()); + assert!(parse_install_url("tabularis://install").is_none()); + } +} diff --git a/src-tauri/src/plugins/installer.rs b/src-tauri/src/plugins/installer.rs index 861c98b3..8975f15c 100644 --- a/src-tauri/src/plugins/installer.rs +++ b/src-tauri/src/plugins/installer.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct InstalledPluginInfo { @@ -15,8 +16,12 @@ pub struct InstalledPluginInfo { #[derive(Deserialize)] struct InstalledPluginManifest { - id: String, + /// Legacy manifests carried an explicit `id`; the canonical schema uses + /// `name` as the slug/identity, so this is optional and falls back to `name`. + #[serde(default)] + id: Option, name: String, + /// The registry guarantees `version` in the manifest (`.tabularium`). version: String, description: String, } @@ -32,16 +37,42 @@ pub fn get_plugins_dir() -> Result { Ok(plugins_dir) } -pub(crate) fn read_plugin_info_from_dir(path: &Path) -> Result { - let manifest_path = path.join("manifest.json"); - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", manifest_path, e))?; +/// Canonical plugin bundle manifest. JSON content. The preferred manifest; the +/// only fallback is the removable `manifest.json` legacy path in `read_manifest` +/// (see `COMPAT(registry-ga)`), which goes away once all plugins republish. +const MANIFEST_FILE: &str = ".tabularium"; + +/// Whether a directory contains a `.tabularium` bundle manifest. +pub fn has_manifest(dir: &Path) -> bool { + dir.join(MANIFEST_FILE).exists() +} + +/// Reads and deserialises a plugin bundle's `.tabularium` manifest (JSON). +pub fn read_manifest(dir: &Path) -> Result { + let path = dir.join(MANIFEST_FILE); + if !path.exists() { + // COMPAT(registry-ga): fall back to legacy manifest.json. + if let Some(legacy) = crate::plugins::compat::read_legacy_manifest::(dir) { + log::warn!("Using legacy manifest.json in {:?} — republish as .tabularium", dir); + return legacy; + } + return Err(format!( + "No .tabularium manifest in {:?} — this plugin bundle must ship a .tabularium (JSON)", + dir + )); + } + let manifest_str = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", path, e))?; + serde_json::from_str(&manifest_str) + .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", path, e)) +} - let manifest: InstalledPluginManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", manifest_path, e))?; +pub(crate) fn read_plugin_info_from_dir(path: &Path) -> Result { + let manifest: InstalledPluginManifest = read_manifest(path)?; + let id = manifest.id.unwrap_or_else(|| manifest.name.clone()); Ok(InstalledPluginInfo { - id: manifest.id, + id, name: manifest.name, version: manifest.version, description: manifest.description, @@ -53,7 +84,11 @@ pub fn read_installed_plugin(plugin_id: &str) -> Result Result<(), String> { +pub async fn download_and_install( + plugin_id: &str, + download_url: &str, + expected_sha256: Option<&str>, +) -> Result<(), String> { let plugins_dir = get_plugins_dir()?; let tmp_dir = plugins_dir.join(format!(".tmp-{}", plugin_id)); let final_dir = plugins_dir.join(plugin_id); @@ -111,6 +146,31 @@ pub async fn download_and_install(plugin_id: &str, download_url: &str) -> Result content_type ); + // Verify SHA-256 if the registry advertised one. The Tabularium + // registry signs releases with a sha256 in the integrity envelope + // (see https://tabularium.wiki/docs/#/consuming) — refusing to install + // on mismatch is what protects users from a tampered upstream asset. + // The legacy GitHub-hosted registry doesn't publish hashes, so this + // check is opt-in per call. + if let Some(expected) = expected_sha256 { + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let actual = format!("{:x}", hasher.finalize()); + if !actual.eq_ignore_ascii_case(expected) { + log::error!( + "Plugin '{}' SHA-256 mismatch: expected {}, got {}", + plugin_id, + expected, + actual + ); + return Err(format!( + "SHA-256 mismatch for plugin '{}': expected {}, got {} — asset may be tampered or corrupted", + plugin_id, expected, actual + )); + } + log::info!("Plugin '{}' SHA-256 verified ({})", plugin_id, actual); + } + // Extract to temp dir fs::create_dir_all(&tmp_dir).map_err(|e| format!("Failed to create temp directory: {}", e))?; @@ -168,20 +228,17 @@ pub async fn download_and_install(plugin_id: &str, download_url: &str) -> Result } } - // Validate manifest.json exists - let manifest_path = tmp_dir.join("manifest.json"); - if !manifest_path.exists() { + // Validate the bundle ships a `.tabularium` manifest and that it deserialises + // into a well-formed manifest with the required fields (notably `version` — + // the strict-mode drift catch). + if !has_manifest(&tmp_dir) { fs::remove_dir_all(&tmp_dir).ok(); - return Err("Plugin archive does not contain manifest.json".to_string()); + return Err("Plugin archive does not contain a .tabularium manifest".to_string()); } - - // Validate manifest.json parses correctly - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read manifest.json: {}", e))?; - serde_json::from_str::(&manifest_str).map_err(|e| { + if let Err(e) = read_manifest::(&tmp_dir) { fs::remove_dir_all(&tmp_dir).ok(); - format!("Invalid manifest.json: {}", e) - })?; + return Err(format!("Invalid plugin manifest: {}", e)); + } // Remove existing plugin dir if present if final_dir.exists() { @@ -234,8 +291,7 @@ pub fn list_installed() -> Result, String> { } } - let manifest_path = path.join("manifest.json"); - if !manifest_path.exists() { + if !has_manifest(&path) { continue; } diff --git a/src-tauri/src/plugins/manager.rs b/src-tauri/src/plugins/manager.rs index 9121034c..dc42e70f 100644 --- a/src-tauri/src/plugins/manager.rs +++ b/src-tauri/src/plugins/manager.rs @@ -30,8 +30,11 @@ pub fn get_plugin_startup_errors() -> Vec { #[derive(Serialize, Deserialize)] pub struct ConfigManifest { - pub id: String, + /// Legacy field; the canonical schema uses `name` as the identity/slug. + #[serde(default)] + pub id: Option, pub name: String, + /// The registry guarantees `version` in the manifest (`.tabularium`). pub version: String, pub description: String, #[serde(default)] @@ -145,31 +148,23 @@ pub async fn load_plugin_from_dir( interpreter_override: Option, settings: HashMap, ) -> Result<(), String> { - let manifest_path = path.join("manifest.json"); - if !manifest_path.exists() { - return Err(format!("manifest.json not found in {:?}", path)); - } - - let manifest_str = fs::read_to_string(&manifest_path) - .map_err(|e| format!("Failed to read plugin manifest {:?}: {}", manifest_path, e))?; - - let config: ConfigManifest = serde_json::from_str(&manifest_str) - .map_err(|e| format!("Failed to parse plugin manifest {:?}: {}", manifest_path, e))?; + let config: ConfigManifest = crate::plugins::installer::read_manifest(path)?; // Refuse plugins that claim a built-in driver id. Registration is a plain // insert keyed by id, so otherwise a plugin with id "mysql"/"postgres"/ // "sqlite" would shadow the built-in driver and receive existing // connections' resolved credentials. + let plugin_id = config.id.clone().unwrap_or_else(|| config.name.clone()); const BUILTIN_DRIVER_IDS: [&str; 3] = ["mysql", "postgres", "sqlite"]; - if BUILTIN_DRIVER_IDS.contains(&config.id.as_str()) { + if BUILTIN_DRIVER_IDS.contains(&plugin_id.as_str()) { return Err(format!( "Plugin id '{}' collides with a built-in driver and was refused", - config.id + plugin_id )); } let manifest = PluginManifest { - id: config.id, + id: plugin_id, name: config.name, version: config.version, description: config.description, diff --git a/src-tauri/src/plugins/mod.rs b/src-tauri/src/plugins/mod.rs index b2836f1e..07d2c103 100644 --- a/src-tauri/src/plugins/mod.rs +++ b/src-tauri/src/plugins/mod.rs @@ -1,9 +1,12 @@ pub mod commands; +pub mod compat; // COMPAT(registry-ga): remove with the BC layer +pub mod deep_link; pub mod driver; pub mod installer; pub mod manager; pub mod registry; pub mod rpc; +pub mod tabularium; #[cfg(test)] mod tests; diff --git a/src-tauri/src/plugins/registry.rs b/src-tauri/src/plugins/registry.rs index 3c67769a..de7802df 100644 --- a/src-tauri/src/plugins/registry.rs +++ b/src-tauri/src/plugins/registry.rs @@ -2,8 +2,53 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -const REGISTRY_URL: &str = - "https://raw.githubusercontent.com/TabularisDB/tabularis/main/plugins/registry.json"; +use crate::plugins::tabularium; + +/// Built-in Tabularium registry used when the user has not pinned one +/// in `config.json`. Operators can override via `tabularium_registry_url`. +pub const DEFAULT_TABULARIUM_URL: &str = "https://registry.tabularis.dev"; + +/// Resolved action for a deeplink/install decision, derived from the installed +/// version vs the target version. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum InstallAction { + /// Not installed — offer install. + Install, + /// Installed, older than target — offer update. + Update, + /// Installed at >= target (incl. equal and downgrade links) — no action. + UpToDate, +} + +/// SemVer-aware classification. The registry requires semver-formatted +/// versions; if either side fails to parse we degrade to string comparison +/// (equal => up-to-date, else => update) and log, rather than crash callers. +pub fn classify_install(installed: Option<&str>, target: &str) -> InstallAction { + let Some(installed) = installed else { + return InstallAction::Install; + }; + match (semver::Version::parse(installed), semver::Version::parse(target)) { + (Ok(cur), Ok(tgt)) => { + if cur < tgt { + InstallAction::Update + } else { + InstallAction::UpToDate + } + } + _ => { + log::warn!( + "Non-semver version compare (installed={:?}, target={:?}); string fallback", + installed, target + ); + if installed == target { + InstallAction::UpToDate + } else { + InstallAction::Update + } + } + } +} #[derive(Serialize, Deserialize, Clone, Debug)] pub struct PluginRegistry { @@ -11,7 +56,7 @@ pub struct PluginRegistry { pub plugins: Vec, } -#[derive(Serialize, Deserialize, Clone, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug, Default)] pub struct RegistryPlugin { pub id: String, pub name: String, @@ -20,6 +65,42 @@ pub struct RegistryPlugin { pub homepage: String, pub latest_version: String, pub releases: Vec, + // -- Richer Tabularium-only fields. All optional so the legacy flat + // GitHub registry deserializes unchanged (Serde defaults to None / + // empty Vec for absent fields). + /// URL of the plugin's square logo, when the manifest declares one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Repository URL — surfaced separately from `homepage` because the + /// Tabularium API distinguishes them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + /// Admin-defined kind from `/api/kinds` (e.g. `driver`, `theme`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Tags / categories the author declared on the manifest. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Optional category from the registry's facets. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// Aggregate download count, when the registry tracks it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Base URL of the registry that served this plugin — set by the + /// preview / catalogue commands so the frontend can link card titles + /// to the registry's detail page (`/plugins/`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_base_url: Option, + /// Concrete database the driver connects to (manifest `extensions.engine`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + /// Data-model families (manifest `extensions.paradigms`), primary first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paradigms: Vec, + /// Registry-assigned verification flag (top-level `verified`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub verified: bool, } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -48,6 +129,36 @@ pub struct RegistryPluginWithStatus { pub installed_version: Option, pub update_available: bool, pub platform_supported: bool, + // -- Richer Tabularium-only fields, surfaced verbatim to the frontend. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Base URL of the registry that served this plugin. Lets the frontend + /// link the card title to the registry's detail page (`/plugins/`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub registry_base_url: Option, + /// Concrete database the driver connects to (manifest `extensions.engine`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub engine: Option, + /// Data-model families (manifest `extensions.paradigms`), primary first. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub paradigms: Vec, + /// Registry-assigned verification flag (top-level `verified`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub verified: bool, + /// Deeplink-only: resolved action (install / update / up_to_date) for the + /// confirmation modal. `None` outside the deeplink preview path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub install_action: Option, } pub fn get_current_platform() -> String { @@ -63,17 +174,81 @@ pub fn get_current_platform() -> String { } } -pub async fn fetch_registry(custom_url: Option<&str>) -> Result { - let url = custom_url.unwrap_or(REGISTRY_URL); +/// Fetch a Tabularium-flavoured registry and adapt it to the legacy +/// `PluginRegistry` shape. The plugin list endpoint omits per-release detail, +/// so for each plugin we follow up with `GET /api/plugins/{slug}` to get the +/// full release list — the rest of the install pipeline (frontend cards, +/// version picker, asset resolution) needs `releases[].assets` populated. +pub async fn fetch_tabularium_registry(base_url: &str) -> Result { + let list = tabularium::fetch_plugin_list(base_url).await?; + + // Fetch every plugin's detail concurrently instead of N sequential + // round-trips. A failed detail call degrades to the list item (entry + // visible but not installable — matches "platform unsupported" UX). + let plugins: Vec = + futures::future::join_all(list.into_iter().map(|item| async move { + let slug = item.id.clone(); + match tabularium::fetch_plugin_detail(base_url, &slug).await { + Ok(detail) => detail, + Err(err) => { + log::warn!( + "Tabularium detail fetch failed for {}: {} — falling back to list item", + slug, + err + ); + item + } + } + })) + .await; - let response = reqwest::get(url) - .await - .map_err(|e| format!("Failed to fetch plugin registry: {}", e))?; + Ok(PluginRegistry { + schema_version: 1, + plugins, + }) +} - let registry: PluginRegistry = response - .json() - .await - .map_err(|e| format!("Failed to parse plugin registry: {}", e))?; +/// Thin wrapper so callers don't have to import the SDK adapter type. +pub use crate::plugins::tabularium::AssetResolution as TabulariumAssetResolution; + +pub async fn resolve_tabularium_asset( + base_url: &str, + slug: &str, + version: &str, + platform: &str, +) -> Result { + tabularium::resolve_asset(base_url, slug, version, platform).await +} - Ok(registry) +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_not_installed_is_install() { + assert_eq!(classify_install(None, "1.2.3"), InstallAction::Install); + } + + #[test] + fn classify_older_installed_is_update() { + assert_eq!(classify_install(Some("1.0.0"), "1.2.3"), InstallAction::Update); + assert_eq!(classify_install(Some("0.9.0"), "0.10.0"), InstallAction::Update); + } + + #[test] + fn classify_equal_is_up_to_date() { + assert_eq!(classify_install(Some("1.2.3"), "1.2.3"), InstallAction::UpToDate); + } + + #[test] + fn classify_newer_installed_is_up_to_date() { + // Downgrade link: never auto-downgrade. + assert_eq!(classify_install(Some("2.0.0"), "1.2.3"), InstallAction::UpToDate); + } + + #[test] + fn classify_unparseable_falls_back_to_string_compare() { + assert_eq!(classify_install(Some("weird"), "weird"), InstallAction::UpToDate); + assert_eq!(classify_install(Some("weird"), "other"), InstallAction::Update); + } } diff --git a/src-tauri/src/plugins/tabularium.rs b/src-tauri/src/plugins/tabularium.rs new file mode 100644 index 00000000..6b2f6cbe --- /dev/null +++ b/src-tauri/src/plugins/tabularium.rs @@ -0,0 +1,399 @@ +//! Tabularium registry client. +//! +//! Tabularium is a self-hosted plugin registry (https://tabularium.wiki). +//! HTTP transport and typed deserialization are delegated to the +//! [`tabularium_sdk`] crate — a progenitor-generated client kept in sync +//! with the registry's OpenAPI spec. This module only adapts the SDK's +//! response shapes into the legacy `RegistryPlugin` representation that +//! the rest of Tabularis already speaks. +//! +//! Endpoints used: +//! * `list_plugins` — paginated catalogue +//! * `get_plugin` — detail + releases (each with per-asset metadata) +//! * `get_release_integrity` — JWS / sha256 envelope (used in installer) + +use std::collections::HashMap; + +use tabularium_sdk::Client; + +use crate::plugins::registry::{PluginRelease, RegistryPlugin}; + +/// Strips a trailing `/` so the SDK doesn't end up with `//api/...` URLs. +fn normalise_base(base: &str) -> &str { + base.trim_end_matches('/') +} + +/// Map a Tabularium platform key (the registry stores them as +/// `linux-x64` / `darwin-arm64` / `win-x64` / `universal`, which already +/// matches Tabularis) into the legacy key. Kept as a function in case +/// the registry ever introduces variants that need re-keying. +fn normalise_platform_key(raw: &str) -> String { + match raw { + // Common aliases — defensive in case the registry serves these. + "linux-amd64" | "linux-x86_64" => "linux-x64".to_string(), + "linux-aarch64" => "linux-arm64".to_string(), + "darwin-amd64" | "darwin-x86_64" | "macos-x64" => "darwin-x64".to_string(), + "darwin-aarch64" | "macos-arm64" => "darwin-arm64".to_string(), + "windows-x64" | "win-amd64" | "windows-amd64" => "win-x64".to_string(), + _ => raw.to_string(), + } +} + +/// Build a fresh SDK client against the operator-configured base URL. +/// The client is cheap to construct (wraps a `reqwest::Client`), so callers +/// don't need to cache it for one-shot fetches. +fn make_client(base_url: &str) -> Client { + Client::new(normalise_base(base_url)) +} + +/// Fetch the first page of plugins. The SDK exposes pagination via +/// `limit(...).page(...)`; we ask for the maximum the registry serves +/// (200 fits the typical Tabularis install in one round-trip). +pub async fn fetch_plugin_list(base_url: &str) -> Result, String> { + let client = make_client(base_url); + let resp = client + .list_plugins() + .limit("200") + .send() + .await + .map_err(|e| format!("Tabularium list_plugins failed: {}", e))?; + let body = resp.into_inner(); + Ok(body.plugins.into_iter().map(list_item_to_plugin).collect()) +} + +/// Fetch full detail for one plugin (releases + per-asset sha256/url). +pub async fn fetch_plugin_detail( + base_url: &str, + slug: &str, +) -> Result { + let client = make_client(base_url); + let resp = client + .get_plugin() + .slug(slug) + .send() + .await + .map_err(|e| format!("Tabularium get_plugin '{}' failed: {}", slug, e))?; + Ok(detail_to_plugin(resp.into_inner())) +} + +/// Splits a platform key (`{os}-{arch}`, e.g. `linux-x64`) into the separate +/// `os` / `arch` the tracked-download endpoints expect. A key without a `-` +/// yields `(key, "")`. +fn split_platform(platform: &str) -> (&str, &str) { + platform.split_once('-').unwrap_or((platform, "")) +} + +/// Builds the registry's **tracked** download URL for a specific version: +/// `{base}/api/plugins/{slug}/releases/{version}?os={os}&arch={arch}&redirect=1`. +/// Hitting this endpoint increments the plugin's download counter, then +/// 302-redirects to the real asset (reqwest follows the redirect automatically). +pub fn tracked_download_url(base_url: &str, slug: &str, version: &str, platform: &str) -> String { + let base = base_url.trim_end_matches('/'); + let (os, arch) = split_platform(platform); + format!( + "{}/api/plugins/{}/releases/{}?os={}&arch={}&redirect=1", + base, slug, version, os, arch + ) +} + +/// Builds the registry's **tracked latest** download URL: +/// `{base}/api/plugins/{slug}/latest?os={os}&arch={arch}&redirect=1`. +/// Used when installing the latest version (no pinned version) so the registry +/// records a "latest" download; like the versioned endpoint it 302-redirects to +/// the asset. +pub fn tracked_latest_download_url(base_url: &str, slug: &str, platform: &str) -> String { + let base = base_url.trim_end_matches('/'); + let (os, arch) = split_platform(platform); + format!( + "{}/api/plugins/{}/latest?os={}&arch={}&redirect=1", + base, slug, os, arch + ) +} + +/// Per-platform asset resolution for installation. Returns the download URL +/// plus the SHA-256 the registry expects (when published — `None` for legacy +/// releases pre-Phase-2 integrity backfill). +pub struct AssetResolution { + pub download_url: String, + pub expected_sha256: Option, +} + +pub async fn resolve_asset( + base_url: &str, + slug: &str, + version: &str, + platform: &str, +) -> Result { + let client = make_client(base_url); + let raw = client + .get_plugin() + .slug(slug) + .send() + .await + .map_err(|e| format!("Tabularium get_plugin '{}' failed: {}", slug, e))? + .into_inner(); + let release = raw + .releases + .iter() + .find(|r| r.version == version) + .ok_or_else(|| format!("No release '{}' for plugin '{}'", version, slug))?; + + // The per-platform asset map (`releases[].assets`) is left untyped by the + // SDK (`patternProperties` in OpenAPI → `serde_json::Map`). + // We read `url` and `sha256` directly off the JSON object. + let pick = pick_asset_entry(&release.assets, platform); + let entry = match pick { + Some(e) => e, + None => { + return Err(format!( + "Plugin '{}' has no asset for platform '{}'", + slug, platform + )) + } + }; + let url = entry + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Tabularium asset for '{}' is missing 'url'", slug))?; + let sha = entry + .get("sha256") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Ok(AssetResolution { + download_url: url, + expected_sha256: sha, + }) +} + +/// Picks the JSON object describing the asset for the requested platform. +/// Tries the exact key first, then known aliases (matched via +/// [`normalise_platform_key`]), then the `universal` fallback. +fn pick_asset_entry<'a>( + assets: &'a serde_json::Map, + platform: &str, +) -> Option<&'a serde_json::Map> { + let direct = assets.get(platform).and_then(|v| v.as_object()); + if direct.is_some() { + return direct; + } + for (k, v) in assets.iter() { + if normalise_platform_key(k) == platform { + if let Some(obj) = v.as_object() { + return Some(obj); + } + } + } + assets.get("universal").and_then(|v| v.as_object()) +} + +// ----------------------------------------------------------------------------- +// SDK → legacy shape adapters +// ----------------------------------------------------------------------------- + +fn list_item_to_plugin(item: tabularium_sdk::types::ListPluginsResponsePluginsItem) -> RegistryPlugin { + let homepage = choose_homepage(item.homepage.clone(), item.repo_url.clone()); + let facets = serde_json::to_value(&item).unwrap_or(serde_json::Value::Null); + let (engine, paradigms, verified) = extract_driver_facets(&facets); + RegistryPlugin { + id: item.id, + name: item.name, + description: item.description, + author: item.author, + homepage, + latest_version: item.latest_version.unwrap_or_default(), + releases: Vec::new(), + icon: item.icon_url, + repo_url: nonempty(item.repo_url), + kind: None, // not on list items — fetched via detail + tags: item.tags, + category: item.category, + downloads: Some(item.downloads.max(0.0) as u64), + registry_base_url: None, + engine, + paradigms, + verified, + } +} + +fn detail_to_plugin(detail: tabularium_sdk::types::GetPluginResponse) -> RegistryPlugin { + let latest = detail.latest_version.clone().unwrap_or_else(|| { + detail + .releases + .first() + .map(|r| r.version.clone()) + .unwrap_or_default() + }); + + // `kind` is folded into `tags` by the registry (Tabularium spec) so the + // SDK doesn't expose a separate field. Recover it as the first tag that + // matches the registry's kind pattern (`^[a-z0-9][a-z0-9-]*$`) — best + // effort, falls back to None when the heuristic doesn't match. + let kind = detail + .tags + .iter() + .find(|t| !t.is_empty() && t.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')) + .cloned(); + + let facets = serde_json::to_value(&detail).unwrap_or(serde_json::Value::Null); + let (engine, paradigms, verified) = extract_driver_facets(&facets); + + RegistryPlugin { + id: detail.id, + name: detail.name, + description: detail.description, + author: detail.author, + homepage: choose_homepage(detail.homepage.clone(), detail.repo_url.clone()), + latest_version: latest, + releases: detail.releases.iter().map(release_to_legacy).collect(), + icon: detail.icon_url, + repo_url: nonempty(detail.repo_url), + kind, + tags: detail.tags, + category: detail.category, + downloads: Some(detail.downloads.max(0.0) as u64), + registry_base_url: None, + engine, + paradigms, + verified, + } +} + +fn nonempty(s: String) -> Option { + if s.is_empty() { + None + } else { + Some(s) + } +} + +fn release_to_legacy(r: &tabularium_sdk::types::GetPluginResponseReleasesItem) -> PluginRelease { + let mut assets: HashMap = HashMap::new(); + for (platform_raw, value) in r.assets.iter() { + let Some(url) = value.get("url").and_then(|v| v.as_str()) else { + continue; + }; + let key = normalise_platform_key(platform_raw); + assets.entry(key).or_insert_with(|| url.to_string()); + } + PluginRelease { + version: r.version.clone(), + min_tabularis_version: r.min_runtime_version.clone(), + assets, + } +} + +/// Read the Tabularis driver facets out of a serialized registry plugin: +/// `verified` (top-level) and `engine`/`paradigms` (nested under `extensions`). +/// Works off `serde_json::Value` so it does not depend on the exact generated +/// SDK field types — only that the SDK round-trips these keys. +fn extract_driver_facets(item: &serde_json::Value) -> (Option, Vec, bool) { + let verified = item.get("verified").and_then(|v| v.as_bool()).unwrap_or(false); + let ext = item.get("extensions"); + let engine = ext + .and_then(|e| e.get("engine")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let paradigms = ext + .and_then(|e| e.get("paradigms")) + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + (engine, paradigms, verified) +} + +/// Picks the first non-empty value between explicit `homepage` and the +/// repo URL — the legacy registry only carries a single homepage field, +/// so we collapse both Tabularium fields into one. +fn choose_homepage(homepage: String, repo: String) -> String { + if !homepage.is_empty() { + homepage + } else { + repo + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_driver_facets_reads_engine_paradigms_verified() { + let json = serde_json::json!({ + "verified": true, + "extensions": { "engine": "firestore", "paradigms": ["document", "vector"] } + }); + let (engine, paradigms, verified) = extract_driver_facets(&json); + assert_eq!(engine.as_deref(), Some("firestore")); + assert_eq!(paradigms, vec!["document".to_string(), "vector".to_string()]); + assert!(verified); + } + + #[test] + fn extract_driver_facets_defaults_when_absent() { + let json = serde_json::json!({ "name": "x" }); + let (engine, paradigms, verified) = extract_driver_facets(&json); + assert_eq!(engine, None); + assert!(paradigms.is_empty()); + assert!(!verified); + } + + #[test] + fn normalise_platform_key_handles_aliases() { + assert_eq!(normalise_platform_key("linux-x64"), "linux-x64"); + assert_eq!(normalise_platform_key("linux-amd64"), "linux-x64"); + assert_eq!(normalise_platform_key("linux-aarch64"), "linux-arm64"); + assert_eq!(normalise_platform_key("darwin-aarch64"), "darwin-arm64"); + assert_eq!(normalise_platform_key("windows-x64"), "win-x64"); + assert_eq!(normalise_platform_key("universal"), "universal"); + assert_eq!(normalise_platform_key("freebsd-x64"), "freebsd-x64"); + } + + #[test] + fn choose_homepage_prefers_explicit_homepage() { + assert_eq!( + choose_homepage("https://home".into(), "https://repo".into()), + "https://home" + ); + assert_eq!( + choose_homepage(String::new(), "https://repo".into()), + "https://repo" + ); + assert_eq!(choose_homepage(String::new(), String::new()), ""); + } + + #[test] + fn builds_tracked_url_from_platform_key() { + // Platform keys are `{os}-{arch}`; the endpoint wants them split. + assert_eq!( + tracked_download_url("https://registry.tabularis.dev", "firestore", "0.2.0", "linux-x64"), + "https://registry.tabularis.dev/api/plugins/firestore/releases/0.2.0?os=linux&arch=x64&redirect=1" + ); + assert_eq!( + tracked_download_url("https://registry.tabularis.dev/", "duckdb", "1.0.0", "darwin-arm64"), + "https://registry.tabularis.dev/api/plugins/duckdb/releases/1.0.0?os=darwin&arch=arm64&redirect=1" + ); + } + + #[test] + fn builds_tracked_latest_url() { + assert_eq!( + tracked_latest_download_url("https://registry.tabularis.dev/", "firestore", "linux-x64"), + "https://registry.tabularis.dev/api/plugins/firestore/latest?os=linux&arch=x64&redirect=1" + ); + } + + #[test] + fn tracked_url_handles_platform_without_dash() { + // Defensive: an unsplittable platform still produces a usable URL. + assert_eq!( + tracked_download_url("https://r.example", "x", "1.0.0", "universal"), + "https://r.example/api/plugins/x/releases/1.0.0?os=universal&arch=&redirect=1" + ); + } +} diff --git a/src-tauri/src/plugins/tests.rs b/src-tauri/src/plugins/tests.rs index d04ab2d9..cf654fa4 100644 --- a/src-tauri/src/plugins/tests.rs +++ b/src-tauri/src/plugins/tests.rs @@ -5,33 +5,58 @@ use tempfile::tempdir; use super::installer::read_plugin_info_from_dir; #[test] -fn reads_installed_plugin_info_from_manifest() { +fn reads_canonical_tabularium_manifest() { + // The canonical bundle ships `.tabularium` (JSON content). It drops `id` + // (name is the slug) and keeps the required `version`; identity falls back + // to `name`. let dir = tempdir().expect("temp dir"); - let manifest_path = dir.path().join("manifest.json"); fs::write( - &manifest_path, + dir.path().join(".tabularium"), r#"{ - "id": "google-sheets", - "name": "Google Sheets", - "version": "0.2.0", - "description": "Query Sheets" + "name": "firestore", + "kind": "driver", + "version": "0.3.8", + "description": "Firestore driver" }"#, ) - .expect("write manifest"); + .expect("write .tabularium"); let plugin = read_plugin_info_from_dir(dir.path()).expect("read manifest"); - assert_eq!(plugin.id, "google-sheets"); - assert_eq!(plugin.name, "Google Sheets"); + assert_eq!(plugin.id, "firestore"); + assert_eq!(plugin.name, "firestore"); + assert_eq!(plugin.version, "0.3.8"); + assert_eq!(plugin.description, "Firestore driver"); +} + +#[test] +fn falls_back_to_legacy_manifest_json() { + // COMPAT(registry-ga): a bundle that ships only the legacy manifest.json + // now loads successfully via the compat fallback until the publisher + // migrates to .tabularium. + let dir = tempdir().expect("temp dir"); + fs::write( + dir.path().join("manifest.json"), + r#"{ "name": "google-sheets", "version": "0.2.0", "description": "Query Sheets" }"#, + ) + .expect("write manifest"); + + let plugin = read_plugin_info_from_dir(dir.path()).expect("legacy fallback must succeed"); + assert_eq!(plugin.name, "google-sheets"); assert_eq!(plugin.version, "0.2.0"); - assert_eq!(plugin.description, "Query Sheets"); +} + +#[test] +fn errors_when_no_manifest_present() { + let dir = tempdir().expect("temp dir"); + let error = read_plugin_info_from_dir(dir.path()).expect_err("no manifest"); + assert!(error.contains("No .tabularium manifest")); } #[test] fn returns_error_for_invalid_manifest() { let dir = tempdir().expect("temp dir"); - let manifest_path = dir.path().join("manifest.json"); - fs::write(&manifest_path, "{ invalid json").expect("write manifest"); + fs::write(dir.path().join(".tabularium"), "{ invalid json").expect("write manifest"); let error = read_plugin_info_from_dir(dir.path()).expect_err("invalid manifest"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 098bd060..cd1b9d83 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -61,6 +61,11 @@ "https://github.com/TabularisDB/tabularis/releases/latest/download/latest.json" ], "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDY0NzY0QjNEQjI4QjFEQjcKUldTM0hZdXlQVXQyWkRYdmRJOEJhVEpYTit2VXRYS0drTit1bmthSHVzcWlQK09Wb2l5cVpOWXAK" + }, + "deep-link": { + "desktop": { + "schemes": ["tabularis"] + } } } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index e1ce1bec..6c549181 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,9 +21,11 @@ import { UpdateNotificationModal } from "./components/modals/UpdateNotificationM import { CommunityModal } from "./components/modals/CommunityModal"; import { WhatsNewModal } from "./components/modals/WhatsNewModal"; import { AiApprovalGate } from "./components/modals/AiApprovalGate"; +import { PluginInstallConfirmModal } from "./components/modals/PluginInstallConfirmModal"; import { useUpdate } from "./hooks/useUpdate"; import { useChangelog } from "./hooks/useChangelog"; import { useSettings } from "./hooks/useSettings"; +import { useDeepLinkInstall } from "./hooks/useDeepLinkInstall"; import { APP_VERSION } from "./version"; import { isVersionAtMost, isVersionNewer } from "./utils/versionCompare"; @@ -40,6 +42,7 @@ export function App() { } = useUpdate(); const { settings, updateSetting, isLoading: isSettingsLoading } = useSettings(); const [isDebugMode, setIsDebugMode] = useState(false); + const deepLinkInstall = useDeepLinkInstall(); const [isCommunityModalDismissed, setIsCommunityModalDismissed] = useState(false); const lastSeenVersion = localStorage.getItem(WHATS_NEW_VERSION_KEY); @@ -167,6 +170,22 @@ export function App() { /> + + { + void deepLinkInstall.confirm(); + }} + onCancel={deepLinkInstall.cancel} + configuredRegistry={settings.tabulariumRegistryUrl ?? null} + /> ); } diff --git a/src/components/modals/NewConnectionModal.tsx b/src/components/modals/NewConnectionModal.tsx index 10abbcf5..27c6ac68 100644 --- a/src/components/modals/NewConnectionModal.tsx +++ b/src/components/modals/NewConnectionModal.tsx @@ -4,6 +4,7 @@ import { X, Check, AlertCircle, + ArrowLeft, Loader2, Database, Settings, @@ -12,9 +13,9 @@ import { CheckSquare, Square, Plug, - Info, Eye, EyeOff, + ShieldCheck, } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; import type { ConnectionAppearance } from "../../contexts/DatabaseContext"; @@ -26,9 +27,9 @@ import { K8sConnectionsModal } from "./K8sConnectionsModal"; import { Select } from "../ui/Select"; import { SlotAnchor } from "../ui/SlotAnchor"; import { useDrivers } from "../../hooks/useDrivers"; +import { useSettings } from "../../hooks/useSettings"; import { usePluginSlotRegistry } from "../../hooks/usePluginSlotRegistry"; import { Modal } from "../ui/Modal"; -import type { PluginManifest } from "../../types/plugins"; import { loadSshConnections, type SshConnection } from "../../utils/ssh"; import { loadK8sConnections, @@ -44,6 +45,30 @@ import { parseConnectionString, toConnectionParams, } from "../../utils/connectionStringParser"; +import { useConnectionCatalogue } from "../../hooks/useConnectionCatalogue"; +import { ConnectionCatalogue } from "./connection/ConnectionCatalogue"; +import { DriverVersionPicker } from "./connection/DriverVersionPicker"; +import { InstallGate } from "./connection/InstallGate"; +import { + resolveEngineSelection, + type EngineGroup, + type CatalogueDriver, +} from "../../utils/connectionCatalogue"; + +// Accent colors per data paradigm, used for driver chips in the configure header. +const PARADIGM_ACCENT: Record = { + sql: "#3b82f6", + relational: "#3b82f6", + nosql: "#10b981", + document: "#10b981", + "key-value": "#14b8a6", + vector: "#a855f7", + graph: "#f59e0b", + timeseries: "#ec4899", + search: "#6366f1", +}; + +const paradigmAccent = (p: string): string => PARADIGM_ACCENT[p] ?? "#64748b"; interface ConnectionParams { driver: string; @@ -155,11 +180,83 @@ export const NewConnectionModal = ({ initialConnection, }: NewConnectionModalProps) => { const { t } = useTranslation(); - const { drivers } = useDrivers(); + const { drivers, refresh: refreshDrivers } = useDrivers(); + const { settings, updateSetting } = useSettings(); + + // ── wizard step ── + const isEditing = Boolean(initialConnection); + const [step, setStep] = useState<"catalogue" | "form">( + isEditing ? "form" : "catalogue", + ); + const [pendingGroup, setPendingGroup] = useState(null); + const catalogue = useConnectionCatalogue(); // ── form state ── const [driver, setDriver] = useState("mysql"); const activeDriver = drivers.find((d) => d.id === driver) ?? drivers[0]; + + // ── driver install state ── + const [installStatus, setInstallStatus] = useState< + "idle" | "installing" | "error" + >("idle"); + const [installError, setInstallError] = useState(); + + const installDriver = async ( + slug: string, + version: string, + ): Promise => { + setInstallStatus("installing"); + setInstallError(undefined); + try { + await invoke("install_plugin", { pluginId: slug, version }); + // install_plugin hot-registers the driver, but the connection modal only + // surfaces external drivers that are in `activeExternalDrivers`. Installing + // from the catalogue is an explicit opt-in, so activate it — otherwise the + // freshly-installed driver is filtered out and selection falls back to a + // built-in (mysql). + await updateSetting( + "activeExternalDrivers", + Array.from(new Set([...(settings.activeExternalDrivers ?? []), slug])), + ); + catalogue.refresh(); + refreshDrivers(); + setInstallStatus("idle"); + return true; + } catch (e) { + setInstallStatus("error"); + setInstallError( + typeof e === "string" ? e : e instanceof Error ? e.message : JSON.stringify(e), + ); + return false; + } + }; + + const activeCatalogueDriver = + catalogue.groups + .flatMap((g) => g.drivers) + .find((d) => d.slug === driver) ?? null; + const activeDriverNotInstalled = + activeCatalogueDriver != null && !activeCatalogueDriver.installed; + + // Accent + glyph for the active driver, preferring the registry's icon URL + // (the real plugin logo) and falling back to the built-in driver glyph. + const driverAccent = + activeCatalogueDriver?.color || + paradigmAccent(activeCatalogueDriver?.paradigms?.[0] ?? "") || + "#64748b"; + const renderDriverGlyph = (size: number) => { + const icon = activeCatalogueDriver?.icon ?? activeDriver?.icon ?? ""; + if (/^https?:\/\//.test(icon) || icon.startsWith("data:")) { + return ( + + ); + } + return getDriverIcon(activeDriver, size); + }; const [name, setName] = useState(""); const [formData, setFormData] = useState>({ host: "localhost", @@ -460,6 +557,10 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setPendingGroup(null); + setInstallStatus("idle"); + setInstallError(undefined); + setStep(initialConnection ? "form" : "catalogue"); if (initialConnection) { setName(initialConnection.name); @@ -565,9 +666,46 @@ export const NewConnectionModal = ({ setConnectionStringError(null); setNameError(false); setDatabasesTabError(false); + setInstallStatus("idle"); + setInstallError(undefined); + }; + + const goToForm = (d: CatalogueDriver) => { + handleDriverChange(d.slug); + setPendingGroup(null); + setStep("form"); + // Picking an already-installed external driver from the catalogue is an + // explicit intent to use it — activate it so the form resolves it instead of + // falling back to a built-in. (Not-installed drivers route through the + // InstallGate, which activates on install.) + if (!d.isBuiltin && d.installed) { + void updateSetting( + "activeExternalDrivers", + Array.from(new Set([...(settings.activeExternalDrivers ?? []), d.slug])), + ); + } + }; + + const handleEngineSelect = (group: EngineGroup) => { + const sel = resolveEngineSelection(group); + if (sel.mode === "pick-driver") { + setPendingGroup(group); + } else if (sel.driver) { + // not-installed drivers are routed to the InstallGate by the form-step render; proceed either way + goToForm(sel.driver); + } }; const testConnection = async () => { + if (step === "catalogue") return; + if (installStatus === "installing") return; + if (activeDriverNotInstalled && activeCatalogueDriver) { + const ok = await installDriver( + activeCatalogueDriver.slug, + activeCatalogueDriver.latestVersion, + ); + if (!ok) return; // banner shows the error; do not proceed + } setStatus("testing"); setMessage(""); setTestResult(null); @@ -615,6 +753,15 @@ export const NewConnectionModal = ({ }; const saveConnection = async () => { + if (step === "catalogue") return; + if (installStatus === "installing") return; + if (activeDriverNotInstalled && activeCatalogueDriver) { + const ok = await installDriver( + activeCatalogueDriver.slug, + activeCatalogueDriver.latestVersion, + ); + if (!ok) return; // banner shows the error; do not proceed + } if (!name.trim()) { setStatus("error"); setMessage(t("newConnection.nameRequired")); @@ -778,13 +925,27 @@ export const NewConnectionModal = ({ context={dbFieldSlotContext} /> ) : ( -
- -

- {t("newConnection.noGeneralSettings", { - defaultValue: "No general settings available for this driver.", - })} -

+
+ + {renderDriverGlyph(24)} + +
+

+ {t("newConnection.noConnectionDetailsTitle", { + defaultValue: "No connection details needed", + })} +

+

+ {t("newConnection.noConnectionDetailsBody", { + driver: activeDriver?.name ?? driver, + defaultValue: + "{{driver}} connects without a host or port. Just give this connection a name and save it. Driver-specific options live in Settings → Plugins.", + })} +

+
) ) : activeDriver?.capabilities?.file_based === true || @@ -1787,103 +1948,192 @@ export const NewConnectionModal = ({ onClose={onClose} overlayClassName="fixed inset-0 bg-black/60 flex items-center justify-center z-[100] backdrop-blur-sm" > -
- {/* ── Top bar: name + close ── */} +
+ {/* ── Top bar: step-aware title / name + progress + close ── */}
-
- { - setName(e.target.value); - if (nameError) setNameError(false); - }} - placeholder={t("newConnection.namePlaceholder")} - autoFocus - autoCorrect="off" - autoCapitalize="off" - autoComplete="off" - spellCheck={false} - className={clsx( - "flex-1 bg-transparent text-base font-semibold outline-none", - nameError - ? "text-red-400 placeholder:text-red-400/60" - : "text-primary placeholder:text-muted/50", - )} - /> - - {activeDriver?.name ?? driver} - + {step === "form" ? ( + <> +
+ { + setName(e.target.value); + if (nameError) setNameError(false); + }} + placeholder={t("newConnection.namePlaceholder")} + autoFocus + autoCorrect="off" + autoCapitalize="off" + autoComplete="off" + spellCheck={false} + className={clsx( + "flex-1 bg-transparent text-base font-semibold outline-none", + nameError + ? "text-red-400 placeholder:text-red-400/60" + : "text-primary placeholder:text-muted/50", + )} + /> + + ) : ( +

+ {pendingGroup + ? t("newConnection.selectDriverTitle", { + defaultValue: "Select a driver", + }) + : t("newConnection.chooseTitle", { + defaultValue: "Choose a database", + })} +

+ )} + + {/* Progress indicator (new connection only) */} + {!isEditing && ( +
+ + + {step === "catalogue" ? "1" : } + + {t("newConnection.stepChoose", { defaultValue: "Choose" })} + + + + + 2 + + {t("newConnection.stepConfigure", { defaultValue: "Configure" })} + +
+ )} +
- {/* ── Main body: left driver list + right form ── */} -
- {/* Left: driver list */} -
- {(() => { - const sortedDrivers = [...drivers].sort((a, b) => { - const aBuiltin = a.is_builtin === true ? 0 : 1; - const bBuiltin = b.is_builtin === true ? 0 : 1; - return aBuiltin - bBuiltin; - }); - const firstExternalIdx = sortedDrivers.findIndex( - (d) => !d.is_builtin, - ); - return ( - <> -

- {t("newConnection.dbType")} -

- {sortedDrivers.map((d: PluginManifest, idx) => ( -
- {/* Separator before first external plugin */} - {idx === firstExternalIdx && ( -
-
-
- - Plugins - -
-
-
- )} - -
+ {/* ── Main body ── */} + {step === "catalogue" ? ( + pendingGroup ? ( + goToForm(d)} + onBack={() => setPendingGroup(null)} + /> + ) : ( + + ) + ) : activeDriverNotInstalled && activeCatalogueDriver ? ( + /* ── install gate: selected driver isn't installed yet ── */ + void installDriver(slug, version)} + onBack={() => setStep("catalogue")} + /> + ) : ( + /* ── form step: driver identity header + tabbed form ── */ +
+ {/* Driver identity header */} +
+ + {renderDriverGlyph(22)} + +
+
+

+ {activeDriver?.name ?? driver} +

+ {activeCatalogueDriver?.verified && ( + + + Verified + + )} + {activeDriver && activeDriver.is_builtin !== true && ( + + v{activeDriver.version} + + )} +
+
+ {(activeCatalogueDriver?.paradigms ?? []).map((p) => ( + + {p} + ))} - - ); - })()} -
+ {activeDriver?.description && ( + + {activeDriver.description} + + )} +
+
+ {!isEditing && ( + + )} +
- {/* Right: form area */} -
{/* Tab bar */}
{( @@ -1922,7 +2172,7 @@ export const NewConnectionModal = ({ key={tab.id} onClick={() => setActiveTab(tab.id)} className={clsx( - "px-4 py-2.5 text-xs font-semibold uppercase tracking-wider transition-colors border-b-2 -mb-px", + "cursor-pointer px-4 py-2.5 text-xs font-semibold uppercase tracking-wider transition-colors border-b-2 -mb-px", activeTab === tab.id ? "border-blue-500 text-blue-400" : "border-transparent text-muted hover:text-secondary", @@ -1959,10 +2209,10 @@ export const NewConnectionModal = ({ : appearanceTabContent}
-
+ )} - {/* ── Footer: test status + actions ── */} -
+ {/* ── Footer: test status + actions (form step only) ── */} + {step === "form" && !activeDriverNotInstalled &&
{/* Test button */}
-
+
}
{/* SSH Management Modal */} diff --git a/src/components/modals/PluginInstallConfirmModal.tsx b/src/components/modals/PluginInstallConfirmModal.tsx new file mode 100644 index 00000000..0d8543f9 --- /dev/null +++ b/src/components/modals/PluginInstallConfirmModal.tsx @@ -0,0 +1,397 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { invoke } from "@tauri-apps/api/core"; +import { + X, + Download, + AlertTriangle, + ExternalLink, + Loader2, + Boxes, + Home, + CheckCircle2, +} from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { Modal } from "../ui/Modal"; +import type { RegistryPluginWithStatus } from "../../types/plugins"; +import type { DeepLinkInstallRequest } from "../../hooks/useDeepLinkInstall"; + +interface PluginInstallConfirmModalProps { + request: DeepLinkInstallRequest | null; + busy: boolean; + error: string | null; + onConfirm: () => void; + onCancel: () => void; + /** The registry URL the user currently has configured (or the default). */ + configuredRegistry?: string | null; +} + +// The backend's `fetch_tabularium_plugin_preview` returns the same shape we +// use for catalogue entries, with `releases` left empty / loose. Reuse the +// type so we get autocomplete for every Tabularium field (icon, kind, tags…). +type PluginPreview = RegistryPluginWithStatus; + +/** + * Shown when a `tabularis://install/` URL arrives via the OS deep-link + * handler. The user must explicitly confirm — a malicious or accidental link + * cannot trigger a silent install. + * + * On mount we fetch the rich plugin record from the registry so the modal + * shows the actual display name, icon, description, kind, tags etc. — not + * just the raw slug. If the fetch fails (offline, plugin removed, hostile + * registry) we degrade gracefully to a minimal view that still lets the user + * cancel or proceed with eyes open. + */ +export const PluginInstallConfirmModal = ({ + request, + busy, + error, + onConfirm, + onCancel, + configuredRegistry, +}: PluginInstallConfirmModalProps) => { + const { t } = useTranslation(); + const [preview, setPreview] = useState(null); + // Starts true for an active request: the modal is keyed by request in App.tsx, + // so each new request remounts this component with fresh state — no synchronous + // reset in the effect needed (which would trigger cascading renders). + const [previewLoading, setPreviewLoading] = useState(() => Boolean(request)); + const [previewError, setPreviewError] = useState(null); + + // Fetch the preview for this request. State is only updated from the async + // callbacks below; the initial empty/loading state comes from the keyed remount. + useEffect(() => { + if (!request) return; + let cancelled = false; + invoke("fetch_tabularium_plugin_preview", { + slug: request.slug, + registryUrl: request.registry ?? null, + // `?version=` in the deep link yields "" — treat it as "no pin". + version: request.version || null, + }) + .then((p) => { + if (cancelled) return; + setPreview(p); + }) + .catch((err) => { + if (cancelled) return; + setPreviewError(String(err)); + }) + .finally(() => { + if (!cancelled) setPreviewLoading(false); + }); + return () => { + cancelled = true; + }; + }, [request]); + + if (!request) return null; + + const requestedRegistry = request.registry ?? null; + const showsRegistryMismatch = + !!requestedRegistry && + !!configuredRegistry && + stripSlash(requestedRegistry) !== stripSlash(configuredRegistry); + + const base = stripSlash( + preview?.registry_base_url ?? requestedRegistry ?? configuredRegistry ?? "", + ); + const pluginPageUrl = base ? `${base}/plugins/${request.slug}` : null; + const targetVersion = + (request.version || null) ?? preview?.latest_version ?? null; + + const action = preview?.install_action ?? "install"; + const isUpToDate = action === "up_to_date"; + const isUpdate = action === "update"; + + const displayName = preview?.name ?? request.slug; + const description = preview?.description ?? null; + const author = preview?.author ?? null; + const kind = preview?.kind ?? null; + const tags = (preview?.tags ?? []).filter((tag) => tag !== kind); + const icon = preview?.icon ?? null; + const homepage = preview?.homepage ?? null; + const homepageDistinct = + !!homepage && (!pluginPageUrl || stripSlash(homepage) !== stripSlash(pluginPageUrl)); + + return ( + +
+ {/* Header */} +
+
+
+ +
+
+

+ {t("deepLink.installTitle")} +

+

+ {t("deepLink.installSubtitle")} +

+
+
+ +
+ + {/* Body */} +
+ {/* Hero: icon + name + version */} +
+ +
+
+ {pluginPageUrl ? ( + + ) : ( + + {displayName} + + )} + {homepageDistinct && homepage && ( + + )} +
+

+ {request.slug} +

+
+ + v{targetVersion ?? "—"} + + {kind && ( + + {kind} + + )} + {tags.slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+
+
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Author + previewLoading hint */} + {(author || previewLoading) && ( +
+ {author ? ( + + {t("settings.plugins.by")}{" "} + {author} + + ) : ( + + )} + {previewLoading && ( + + + {t("deepLink.loadingPreview", { + defaultValue: "Loading details…", + })} + + )} +
+ )} + + {/* Source registry strip */} +
+ + {t("deepLink.registry")} + + {requestedRegistry ? ( + + ) : ( + + {t("deepLink.usingConfigured", { + defaultValue: "using configured registry", + })} + + )} +
+ + {/* Banners */} + {showsRegistryMismatch && ( + }> +

+ {t("deepLink.mismatchTitle")} +

+

+ {t("deepLink.mismatchBody", { + configured: configuredRegistry, + })} +

+
+ )} + {previewError && ( + }> +
+                {previewError}
+              
+
+ )} + {error && ( + }> +
+                {error}
+              
+
+ )} + {isUpToDate && ( + }> +

+ {t("deepLink.alreadyInstalled", { + version: preview?.installed_version ?? targetVersion ?? "", + defaultValue: "Version {{version}} is already installed.", + })} +

+
+ )} +
+ + {/* Footer */} +
+ + {!isUpToDate && ( + + )} +
+
+
+ ); +}; + +function stripSlash(s: string): string { + return s.replace(/\/+$/, "").toLowerCase(); +} + +/** Square plugin logo or a deterministic letter fallback. */ +function PluginIcon({ + icon, + fallbackName, +}: { + icon: string | null; + fallbackName: string; +}) { + const letter = fallbackName.trim().charAt(0).toUpperCase() || "?"; + return ( +
+ {icon ? ( + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + )} + {/* Always render the letter behind the image — visible if the image + fails to load and onError hides it. */} + {!icon && ( + + {letter} + + )} +
+ ); +} + +function Banner({ + tone, + icon, + children, +}: { + tone: "amber" | "red"; + icon: React.ReactNode; + children: React.ReactNode; +}) { + const cls = + tone === "amber" + ? "bg-amber-900/20 border-amber-700/40 text-amber-300" + : "bg-red-900/20 border-red-700/40 text-red-300"; + return ( +
+ {icon} +
{children}
+
+ ); +} diff --git a/src/components/modals/connection/ConnectionCatalogue.tsx b/src/components/modals/connection/ConnectionCatalogue.tsx new file mode 100644 index 00000000..39b00b9c --- /dev/null +++ b/src/components/modals/connection/ConnectionCatalogue.tsx @@ -0,0 +1,203 @@ +import clsx from "clsx"; +import { Search, ShieldCheck, X } from "lucide-react"; +import { useMemo, useState } from "react"; + +import { filterCatalogue, type EngineGroup, type ParadigmFacet } from "../../../utils/connectionCatalogue"; +import { EngineCard } from "./EngineCard"; + +interface ConnectionCatalogueProps { + groups: EngineGroup[]; + facets: ParadigmFacet[]; + loading: boolean; + registryOffline: boolean; + onSelect: (group: EngineGroup) => void; +} + +function sectionLabel(key: string): string { + if (key === "sql") return "SQL"; + if (key === "nosql") return "NoSQL"; + if (key === "key-value") return "Key-Value"; + return key.charAt(0).toUpperCase() + key.slice(1); +} + +export function ConnectionCatalogue({ + groups, + facets, + loading, + registryOffline, + onSelect, +}: ConnectionCatalogueProps) { + const [search, setSearch] = useState(""); + const [selectedParadigms, setSelectedParadigms] = useState([]); + const [verifiedOnly, setVerifiedOnly] = useState(false); + const [installedOnly, setInstalledOnly] = useState(false); + + const visible = useMemo( + () => filterCatalogue(groups, { search, paradigms: selectedParadigms, verifiedOnly, installedOnly }), + [groups, search, selectedParadigms, verifiedOnly, installedOnly], + ); + + const bySection = useMemo(() => { + const map = new Map(); + for (const g of visible) { + const list = map.get(g.primaryParadigm) ?? []; + list.push(g); + map.set(g.primaryParadigm, list); + } + return [...map.entries()]; + }, [visible]); + + const toggleParadigm = (key: string) => + setSelectedParadigms((prev) => + prev.includes(key) ? prev.filter((p) => p !== key) : [...prev, key], + ); + + const hasFilters = + search.length > 0 || selectedParadigms.length > 0 || verifiedOnly || installedOnly; + + const clearFilters = () => { + setSearch(""); + setSelectedParadigms([]); + setVerifiedOnly(false); + setInstalledOnly(false); + }; + + return ( +
+ {/* ── sticky header: search + filters ── */} +
+
+ + setSearch(e.target.value)} + placeholder="Search databases…" + className="w-full rounded-lg border border-default bg-surface-secondary py-2.5 pl-9 pr-9 text-sm text-primary outline-none transition-colors focus:border-blue-500 focus:bg-base" + /> + {search && ( + + )} +
+ +
+ {facets.map((f) => { + const active = selectedParadigms.includes(f.key); + return ( + + ); + })} + + + + + + + {hasFilters && ( + + )} +
+
+ + {/* ── scrollable results ── */} +
+ {registryOffline && ( +

+ Registry unreachable — showing installed and built-in drivers only. +

+ )} + + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+ ) : visible.length === 0 ? ( +
+ +

No databases match your filters.

+ {hasFilters && ( + + )} +
+ ) : ( +
+ {bySection.map(([paradigm, list]) => ( +
+

+ {sectionLabel(paradigm)} + + {list.length} + +

+
+ {list.map((g) => ( + + ))} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/components/modals/connection/DriverVersionPicker.tsx b/src/components/modals/connection/DriverVersionPicker.tsx new file mode 100644 index 00000000..08eeef49 --- /dev/null +++ b/src/components/modals/connection/DriverVersionPicker.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import type { CatalogueDriver, EngineGroup } from '../../../utils/connectionCatalogue'; + +interface DriverVersionPickerProps { + group: EngineGroup; + onChoose: (driver: CatalogueDriver, version: string) => void; + onBack: () => void; +} + +export function DriverVersionPicker({ group, onChoose, onBack }: DriverVersionPickerProps) { + const [selectedSlug, setSelectedSlug] = useState(group.drivers[0]?.slug ?? ''); + const selected = group.drivers.find((d) => d.slug === selectedSlug) ?? group.drivers[0]; + + return ( +
+ +

+ Multiple drivers connect to {group.displayName}. Pick one: +

+
    + {group.drivers.map((d) => ( +
  • + +
  • + ))} +
+
+ Latest v{selected?.latestVersion ?? ""} + +
+
+ ); +} diff --git a/src/components/modals/connection/EngineCard.tsx b/src/components/modals/connection/EngineCard.tsx new file mode 100644 index 00000000..7d35b0cb --- /dev/null +++ b/src/components/modals/connection/EngineCard.tsx @@ -0,0 +1,122 @@ +import clsx from "clsx"; +import { Database, Download, ShieldCheck } from "lucide-react"; +import type { CSSProperties } from "react"; + +import type { PluginManifest } from "../../../types/plugins"; +import type { CatalogueDriver, EngineGroup } from "../../../utils/connectionCatalogue"; +import { getDriverIcon } from "../../../utils/driverUI"; + +interface EngineCardProps { + group: EngineGroup; + onSelect: (group: EngineGroup) => void; +} + +/** Pleasant accent per data-model family, used when a driver declares no color. */ +const PARADIGM_ACCENT: Record = { + sql: "#3b82f6", + nosql: "#10b981", + document: "#10b981", + "key-value": "#14b8a6", + vector: "#a855f7", + graph: "#f59e0b", + timeseries: "#ec4899", + relational: "#3b82f6", + other: "#64748b", +}; + +function accentFor(group: EngineGroup, rep: CatalogueDriver): string { + return rep.color || PARADIGM_ACCENT[group.primaryParadigm] || "#64748b"; +} + +function renderIcon(rep: CatalogueDriver) { + const icon = rep.icon ?? ""; + if (/^https?:\/\//.test(icon) || icon.startsWith("data:")) { + return ; + } + if (rep.isBuiltin) { + return getDriverIcon({ icon, color: rep.color ?? undefined } as PluginManifest, 22); + } + return ; +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1000) return `${(n / 1000).toFixed(1)}k`; + return String(n); +} + +export function EngineCard({ group, onSelect }: EngineCardProps) { + const rep = group.drivers.find((d) => d.isBuiltin) ?? group.drivers[0]; + const accent = accentFor(group, rep); + const driverCount = group.drivers.length; + + return ( + + ); +} diff --git a/src/components/modals/connection/InstallBanner.tsx b/src/components/modals/connection/InstallBanner.tsx new file mode 100644 index 00000000..44ad0283 --- /dev/null +++ b/src/components/modals/connection/InstallBanner.tsx @@ -0,0 +1,33 @@ +import type { CatalogueDriver } from '../../../utils/connectionCatalogue'; + +export type InstallStatus = 'idle' | 'installing' | 'error'; + +interface InstallBannerProps { + driver: CatalogueDriver; + status: InstallStatus; + error?: string; + onInstall: (slug: string, version: string) => void; +} + +export function InstallBanner({ driver, status, error, onInstall }: InstallBannerProps) { + const trigger = () => onInstall(driver.slug, driver.latestVersion); + return ( +
+
+

Driver not installed

+ {status === 'error' && error &&

{error}

} + {status === 'installing' &&

Installing {driver.name}…

} +
+ {status !== 'installing' && ( + + )} +
+ ); +} diff --git a/src/components/modals/connection/InstallGate.tsx b/src/components/modals/connection/InstallGate.tsx new file mode 100644 index 00000000..942567c6 --- /dev/null +++ b/src/components/modals/connection/InstallGate.tsx @@ -0,0 +1,101 @@ +import { AlertTriangle, Database, Loader2 } from "lucide-react"; +import type { CSSProperties } from "react"; + +import type { CatalogueDriver } from "../../../utils/connectionCatalogue"; +import type { InstallStatus } from "./InstallBanner"; + +interface InstallGateProps { + driver: CatalogueDriver; + status: InstallStatus; + error?: string; + onInstall: (slug: string, version: string) => void; + onBack: () => void; +} + +const PARADIGM_ACCENT: Record = { + sql: "#3b82f6", + nosql: "#10b981", + document: "#10b981", + "key-value": "#14b8a6", + vector: "#a855f7", + graph: "#f59e0b", + timeseries: "#ec4899", +}; + +function accentFor(driver: CatalogueDriver): string { + return driver.color || PARADIGM_ACCENT[driver.paradigms[0] ?? ""] || "#64748b"; +} + +function renderIcon(driver: CatalogueDriver) { + const icon = driver.icon ?? ""; + if (/^https?:\/\//.test(icon) || icon.startsWith("data:")) { + return ; + } + return ; +} + +export function InstallGate({ driver, status, error, onInstall, onBack }: InstallGateProps) { + const accent = accentFor(driver); + const unsupported = !driver.platformSupported; + const installing = status === "installing"; + + return ( +
+ + {renderIcon(driver)} + + +
+

{driver.name}

+ {driver.paradigms.length > 0 && ( +

{driver.paradigms.join(" · ")}

+ )} +
+ + {unsupported ? ( +
+
+ + No installable release for your platform yet. +
+

+ This driver has no downloadable build for your OS/architecture. Check the registry for an updated release. +

+
+ ) : ( +
+

+ This driver isn't installed yet. Install it to configure a connection. +

+ {status === "error" && error && ( +

{error}

+ )} + +
+ )} + + +
+ ); +} diff --git a/src/components/settings/PluginsTab.tsx b/src/components/settings/PluginsTab.tsx index be154fe0..f189de69 100644 --- a/src/components/settings/PluginsTab.tsx +++ b/src/components/settings/PluginsTab.tsx @@ -10,6 +10,7 @@ import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke } from "@tauri-apps/api/core"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { RefreshCw, Loader2, @@ -27,6 +28,7 @@ import { Power, Boxes, Search, + Home, } from "lucide-react"; import clsx from "clsx"; import { useSettings } from "../../hooks/useSettings"; @@ -76,7 +78,14 @@ interface PluginCardProps { description: string; version?: string; author?: string; + /** Upstream homepage / repo (shown as secondary link icon when registryPageUrl is also set). */ homepage?: string; + /** Registry detail page. Priority target for the name button. */ + registryPageUrl?: string | null; + /** Square logo URL surfaced by the Tabularium registry. Falls back to the letter band when missing/broken. */ + iconUrl?: string | null; + /** Aggregate download count — surfaces as a small badge if > 0. */ + downloads?: number | null; status?: ReactNode; meta?: ReactNode; actions: ReactNode; @@ -92,6 +101,9 @@ function PluginCard({ version, author, homepage, + registryPageUrl, + iconUrl, + downloads, status, meta, actions, @@ -104,45 +116,74 @@ function PluginCard({ const parsedAuthor = author ? parseAuthor(author) : null; const band = BAND_PALETTES[nameBandIndex(name)]; + // Name button: registry detail page wins; falls back to upstream homepage. + const primaryHref = registryPageUrl ?? homepage ?? null; + // Show a second small homepage icon when BOTH targets exist and they + // point at different places (so the user can still reach the repo). + const secondaryHomepage = + homepage && registryPageUrl && stripTrailingSlash(homepage) !== stripTrailingSlash(registryPageUrl) + ? homepage + : null; + return (
- {/* WordPress-style plugin header band */} + {/* Header band with logo (Tabularium icon) or first-letter fallback. */} {showBand && (
- - {name.trim().charAt(0).toUpperCase()} - + {/* Subtle radial highlight so the band reads as a "platform" surface, not a flat stripe. */} +
+ {iconUrl ? ( + { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + ) : ( + + {name.trim().charAt(0).toUpperCase()} + + )} + {!!downloads && downloads > 0 && ( + + + {formatCount(downloads)} + + )}
)} @@ -164,11 +205,12 @@ function PluginCard({
- {homepage ? ( + {primaryHref ? ( + )} {version && ( - + v{version} )} @@ -191,7 +246,7 @@ function PluginCard({ @@ -209,7 +264,7 @@ function PluginCard({

{meta && ( -
+
{meta}
)} @@ -222,6 +277,16 @@ function PluginCard({ ); } +function stripTrailingSlash(s: string): string { + return s.replace(/\/+$/, ""); +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} + /* ── Version dropdown ── */ interface VersionOption { @@ -554,6 +619,30 @@ export function PluginsTab({ return list; }, [registryPlugins, activeFilter, searchQuery]); + // Refresh the catalogue + drivers when a deep-link install succeeds at + // app level — without this the user has to manually click Refresh after + // the confirm modal closes. + useEffect(() => { + let cleanup: UnlistenFn | null = null; + let mounted = true; + listen("tabularis://plugin-installed", () => { + if (!mounted) return; + refreshRegistry(); + refreshDrivers(); + }) + .then((u) => { + if (mounted) cleanup = u; + else u(); + }) + .catch(() => { + /* ignore — listener is best-effort */ + }); + return () => { + mounted = false; + cleanup?.(); + }; + }, [refreshRegistry, refreshDrivers]); + useEffect(() => { invoke>( "get_plugin_startup_errors", @@ -1134,6 +1223,38 @@ export function PluginsTab({ ) : undefined; + // Tags + kind chips from the Tabularium catalogue. Kind is + // shown as a distinct pill since it drives admin taxonomies; + // remaining tags become muted chips. + const remainingTags = (plugin.tags ?? []).filter( + (t) => t && t !== plugin.kind, + ); + const tagMeta = + plugin.kind || remainingTags.length > 0 ? ( + <> + {plugin.kind && ( + + {plugin.kind} + + )} + {remainingTags.slice(0, 4).map((tag) => ( + + {tag} + + ))} + + ) : undefined; + + // If we know which registry served this plugin, link the + // card title to its detail page on that registry (highest + // priority); the upstream homepage becomes a small icon. + const registryPageUrl = plugin.registry_base_url + ? `${plugin.registry_base_url.replace(/\/+$/, "")}/plugins/${plugin.id}` + : null; + return ( @@ -1281,12 +1406,29 @@ export function PluginsTab({ )}
)} + )}
+ {/* Sticky attribution — pinned to the bottom of the Settings scroll + area regardless of how far the user has scrolled. The negative + horizontal margin pulls it through the parent's p-8 padding so + the top border looks edge-to-edge inside the max-w-5xl. */} +
+ {t("settings.plugins.poweredBy")} + +
+ {/* Modals */} ; editorTheme?: string; editorFontFamily?: string; diff --git a/src/hooks/useConnectionCatalogue.ts b/src/hooks/useConnectionCatalogue.ts new file mode 100644 index 00000000..cdaba531 --- /dev/null +++ b/src/hooks/useConnectionCatalogue.ts @@ -0,0 +1,80 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { PluginManifest, RegistryPluginWithStatus } from '../types/plugins'; +import { + builtinToCatalogueDriver, + groupByEngine, + paradigmFacets, + toCatalogueDriver, + type EngineGroup, + type ParadigmFacet, +} from '../utils/connectionCatalogue'; + +const BUILTIN_META: Record = { + postgres: { engine: 'postgres', paradigms: ['sql'] }, + mysql: { engine: 'mysql', paradigms: ['sql'] }, + sqlite: { engine: 'sqlite', paradigms: ['sql'] }, +}; + +export interface ConnectionCatalogue { + groups: EngineGroup[]; + facets: ParadigmFacet[]; + loading: boolean; + registryOffline: boolean; + refresh: () => void; +} + +export function useConnectionCatalogue(): ConnectionCatalogue { + const [registry, setRegistry] = useState([]); + const [builtins, setBuiltins] = useState([]); + const [loading, setLoading] = useState(true); + const [registryOffline, setRegistryOffline] = useState(false); + const [nonce, setNonce] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + void (async () => { + try { + const drivers = await invoke('get_registered_drivers'); + if (!cancelled) setBuiltins(drivers.filter((d) => d.is_builtin === true)); + } catch { + /* built-ins always have a fallback in useDrivers; ignore here */ + } + try { + const cat = await invoke('fetch_plugin_registry'); + if (!cancelled) { + setRegistry(cat); + setRegistryOffline(false); + } + } catch { + if (!cancelled) setRegistryOffline(true); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [nonce]); + + const groups = useMemo(() => { + const builtinDrivers = builtins.map((m) => { + const meta = BUILTIN_META[m.id] ?? { engine: m.id, paradigms: [] }; + return builtinToCatalogueDriver(m, meta.engine, meta.paradigms); + }); + const registryDrivers = registry + // built-ins are represented from manifests above; skip any registry echo. + // hasOwnProperty (not `in`) so plugin ids like "constructor"/"toString" + // aren't matched against Object.prototype and wrongly hidden. + .filter((p) => !Object.prototype.hasOwnProperty.call(BUILTIN_META, p.id)) + .map(toCatalogueDriver); + return groupByEngine([...builtinDrivers, ...registryDrivers]); + }, [builtins, registry]); + + const facets = useMemo(() => paradigmFacets(groups), [groups]); + const refresh = useCallback(() => setNonce((n) => n + 1), []); + + return { groups, facets, loading, registryOffline, refresh }; +} diff --git a/src/hooks/useDeepLinkInstall.ts b/src/hooks/useDeepLinkInstall.ts new file mode 100644 index 00000000..aff63327 --- /dev/null +++ b/src/hooks/useDeepLinkInstall.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useState } from "react"; +import { listen, type UnlistenFn, emit } from "@tauri-apps/api/event"; +import { invoke } from "@tauri-apps/api/core"; + +/** + * Payload emitted by the Rust backend when the OS hands us a + * `tabularis://install/?version=®istry=` URL. Field names match + * the camelCased Serde rename of `plugins::deep_link::PluginInstallRequest`. + */ +export interface DeepLinkInstallRequest { + slug: string; + version?: string | null; + registry?: string | null; +} + +const EVENT_NAME = "tabularis://plugin-install"; + +interface UseDeepLinkInstallResult { + pending: DeepLinkInstallRequest | null; + /** Run the install on the backend and resolve `true` on success. */ + confirm: () => Promise; + /** Dismiss without installing. */ + cancel: () => void; + /** Latest error from a `confirm()` call. Cleared on `cancel()` / next event. */ + error: string | null; + /** True while `confirm()` is running. */ + busy: boolean; +} + +/** + * Subscribes to `tabularis://plugin-install` Tauri events and exposes the + * pending install request to a confirmation modal. The hook intentionally + * does NOT auto-install — every deep-link arrival requires an explicit + * user click so a malicious URL can't trigger a silent install. + */ +export function useDeepLinkInstall(): UseDeepLinkInstallResult { + const [pending, setPending] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + let cleanup: UnlistenFn | null = null; + let mounted = true; + + // Live event listener for warm handoffs — a tabularis:// URL clicked + // while the app is already running fires here. + listen(EVENT_NAME, (event) => { + if (!mounted) return; + setError(null); + setPending(event.payload); + }) + .then((unlisten) => { + if (mounted) { + cleanup = unlisten; + } else { + unlisten(); + } + }) + .catch((err) => { + console.warn(`Failed to subscribe to ${EVENT_NAME}:`, err); + }); + + // Cold-start replay: when the OS launched Tabularis *because of* the + // tabularis:// URL, the event was emitted before this listener existed. + // The Rust side stashes it in app state; we drain it here. + invoke("consume_pending_deep_link_install") + .then((req) => { + if (!mounted || !req) return; + setError(null); + setPending((current) => current ?? req); + }) + .catch((err) => { + console.warn("consume_pending_deep_link_install failed:", err); + }); + + return () => { + mounted = false; + cleanup?.(); + }; + }, []); + + const cancel = useCallback(() => { + setPending(null); + setError(null); + }, []); + + const confirm = useCallback(async (): Promise => { + if (!pending) return false; + setBusy(true); + setError(null); + try { + await invoke("install_plugin", { + pluginId: pending.slug, + version: pending.version ?? null, + }); + // Tell anything observing the plugin catalogue (PluginsTab, drivers + // list) to refresh. Components subscribe to this in their own effects. + void emit("tabularis://plugin-installed", { slug: pending.slug }); + setPending(null); + return true; + } catch (err) { + setError(String(err)); + return false; + } finally { + setBusy(false); + } + }, [pending]); + + return { pending, confirm, cancel, error, busy }; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f825678b..1c4f9353 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,4 +1,20 @@ { + "deepLink": { + "installTitle": "Plugin aus Registry installieren", + "installSubtitle": "Eine Tabularium-Registry hat Tabularis gebeten, ein Plugin zu installieren.", + "plugin": "Plugin", + "version": "Version", + "registry": "Registry", + "latest": "Neueste", + "installConfirm": "Installieren", + "installing": "Installiere…", + "loadingPreview": "Lade Details…", + "usingConfigured": "konfigurierte Registry", + "mismatchTitle": "Abweichende Registry", + "mismatchBody": "Deine konfigurierte Registry ist {{configured}}. Der Link verweist auf eine andere — bitte vor dem Fortfahren prüfen.", + "updateConfirm": "Auf v{{version}} aktualisieren", + "alreadyInstalled": "Version {{version}} ist bereits installiert." + }, "toolbar": { "filters": "Filter", "toggleFilterPanel": "Strukturiertes Filterpanel ein-/ausblenden", @@ -481,6 +497,8 @@ "downgrade": "Herabstufen auf", "olderVersions": "Ältere Versionen", "noPlugins": "Keine Plugins im Registry verfügbar.", + "poweredBy": "Bereitgestellt von", + "openHomepage": "Homepage öffnen", "searchPlaceholder": "Plugins suchen…", "filterAll": "Alle", "filterInstalled": "Installiert", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 966688a7..4e46d49f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1,4 +1,20 @@ { + "deepLink": { + "installTitle": "Install plugin from registry", + "installSubtitle": "A Tabularium registry asked Tabularis to install a plugin.", + "plugin": "Plugin", + "version": "Version", + "registry": "Registry", + "latest": "Latest", + "installConfirm": "Install", + "installing": "Installing…", + "loadingPreview": "Loading details…", + "usingConfigured": "using configured registry", + "mismatchTitle": "Different registry than your configured one", + "mismatchBody": "Your configured registry is {{configured}}. The link points elsewhere; review before continuing.", + "updateConfirm": "Update to v{{version}}", + "alreadyInstalled": "Version {{version}} is already installed." + }, "toolbar": { "filters": "Filters", "toggleFilterPanel": "Toggle structured filter panel", @@ -502,6 +518,8 @@ "downgrade": "Downgrade to", "olderVersions": "Older versions", "noPlugins": "No plugins available in the registry.", + "poweredBy": "Powered by", + "openHomepage": "Open homepage", "searchPlaceholder": "Search plugins…", "filterAll": "All", "filterInstalled": "Installed", @@ -682,6 +700,7 @@ "createInlineSsh": "Configure SSH Inline", "manageSshConnections": "Manage SSH Connections", "noSshConnections": "No SSH connections available", + "changeDatabase": "Change database", "sslMode": "SSL Mode", "sslModes": { "disable": "Disable", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 15610694..7358f395 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -1,4 +1,18 @@ { + "deepLink": { + "installTitle": "Instalar plugin desde el registro", + "installSubtitle": "Un registro de Tabularium pidió a Tabularis que instale un plugin.", + "plugin": "Plugin", + "version": "Versión", + "registry": "Registro", + "latest": "Última", + "installConfirm": "Instalar", + "installing": "Instalando…", + "loadingPreview": "Cargando detalles…", + "usingConfigured": "registro configurado", + "mismatchTitle": "Registro distinto al configurado", + "mismatchBody": "Tu registro configurado es {{configured}}. El enlace apunta a otro — revisa antes de continuar." + }, "toolbar": { "filters": "Filtros", "toggleFilterPanel": "Panel de filtros estructurados", @@ -486,6 +500,8 @@ "downgrade": "Degradar a", "olderVersions": "Versiones anteriores", "noPlugins": "No hay plugins disponibles en el registro.", + "poweredBy": "Con tecnología de", + "openHomepage": "Abrir página", "searchPlaceholder": "Buscar plugins…", "filterAll": "Todos", "filterInstalled": "Instalados", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 54b93cd8..7a29c469 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,4 +1,18 @@ { + "deepLink": { + "installTitle": "Installer un plugin depuis le registre", + "installSubtitle": "Un registre Tabularium demande à Tabularis d'installer un plugin.", + "plugin": "Plugin", + "version": "Version", + "registry": "Registre", + "latest": "Dernière", + "installConfirm": "Installer", + "installing": "Installation…", + "loadingPreview": "Chargement des détails…", + "usingConfigured": "registre configuré", + "mismatchTitle": "Registre différent de celui configuré", + "mismatchBody": "Votre registre configuré est {{configured}}. Le lien pointe ailleurs — vérifiez avant de continuer." + }, "toolbar": { "filters": "Filtres", "toggleFilterPanel": "Afficher/masquer le panneau de filtres structurés", @@ -481,6 +495,8 @@ "downgrade": "Rétrograder vers", "olderVersions": "Anciennes versions", "noPlugins": "Aucun plugin disponible dans le registre.", + "poweredBy": "Propulsé par", + "openHomepage": "Ouvrir la page", "searchPlaceholder": "Rechercher des plugins…", "filterAll": "Tous", "filterInstalled": "Installés", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 4f0e9273..3b4f335c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,4 +1,18 @@ { + "deepLink": { + "installTitle": "Installa plugin dal registro", + "installSubtitle": "Un registro Tabularium ha chiesto a Tabularis di installare un plugin.", + "plugin": "Plugin", + "version": "Versione", + "registry": "Registro", + "latest": "Ultima", + "installConfirm": "Installa", + "installing": "Installazione…", + "loadingPreview": "Caricamento dettagli…", + "usingConfigured": "registro configurato", + "mismatchTitle": "Registro diverso da quello configurato", + "mismatchBody": "Il tuo registro configurato è {{configured}}. Il link punta altrove — controlla prima di continuare." + }, "toolbar": { "filters": "Filtri", "toggleFilterPanel": "Pannello filtri strutturati", @@ -486,6 +500,8 @@ "downgrade": "Effettua downgrade a", "olderVersions": "Versioni precedenti", "noPlugins": "Nessun plugin disponibile nel registro.", + "poweredBy": "Powered by", + "openHomepage": "Apri homepage", "searchPlaceholder": "Cerca plugin…", "filterAll": "Tutti", "filterInstalled": "Installati", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c73ea7a8..eaf62724 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,4 +1,18 @@ { + "deepLink": { + "installTitle": "レジストリからプラグインをインストール", + "installSubtitle": "Tabularium レジストリが Tabularis にプラグインのインストールを要求しました。", + "plugin": "プラグイン", + "version": "バージョン", + "registry": "レジストリ", + "latest": "最新", + "installConfirm": "インストール", + "installing": "インストール中…", + "loadingPreview": "詳細を読み込み中…", + "usingConfigured": "設定済みレジストリ", + "mismatchTitle": "設定とは異なるレジストリ", + "mismatchBody": "設定済みのレジストリは {{configured}} です。リンクは別の場所を指しています — 続行前にご確認ください。" + }, "toolbar": { "filters": "フィルター", "toggleFilterPanel": "構造化フィルターパネルを切り替え", @@ -495,6 +509,8 @@ "downgrade": "ダウングレード:", "olderVersions": "以前のバージョン", "noPlugins": "レジストリに利用可能なプラグインがありません。", + "poweredBy": "提供:", + "openHomepage": "ホームページを開く", "searchPlaceholder": "プラグインを検索…", "filterAll": "すべて", "filterInstalled": "インストール済み", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 36a56368..1413d611 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1,4 +1,18 @@ { + "deepLink": { + "installTitle": "从注册表安装插件", + "installSubtitle": "Tabularium 注册表请求 Tabularis 安装一个插件。", + "plugin": "插件", + "version": "版本", + "registry": "注册表", + "latest": "最新", + "installConfirm": "安装", + "installing": "正在安装…", + "loadingPreview": "正在加载详情…", + "usingConfigured": "已配置的注册表", + "mismatchTitle": "与已配置的注册表不一致", + "mismatchBody": "你配置的注册表是 {{configured}}。链接指向其他位置 — 请先确认再继续。" + }, "toolbar": { "filters": "筛选器", "toggleFilterPanel": "切换结构化筛选面板", @@ -450,6 +464,8 @@ "downgrade": "降级到", "olderVersions": "旧版本", "noPlugins": "注册表中无可用插件。", + "poweredBy": "技术支持:", + "openHomepage": "打开主页", "searchPlaceholder": "搜索插件…", "filterAll": "全部", "filterInstalled": "已安装", diff --git a/src/types/plugins.ts b/src/types/plugins.ts index ca13ad89..f275c15f 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -102,6 +102,23 @@ export interface RegistryPluginWithStatus { installed_version: string | null; update_available: boolean; platform_supported: boolean; + // Richer Tabularium-only fields. All optional so legacy data still works. + icon?: string | null; + repo_url?: string | null; + kind?: string | null; + tags?: string[]; + category?: string | null; + downloads?: number | null; + /** Base URL of the registry that served this plugin (e.g. https://registry.spitzli.dev). */ + registry_base_url?: string | null; + /** Concrete database the driver connects to (registry manifest extensions.engine). */ + engine?: string | null; + /** Data-model families, primary first (registry manifest extensions.paradigms). */ + paradigms?: string[]; + /** Registry-assigned verification flag. */ + verified?: boolean; + /** Deeplink-only: resolved action for the confirmation modal. */ + install_action?: "install" | "update" | "up_to_date" | null; } export interface InstalledPluginInfo { diff --git a/src/utils/connectionCatalogue.ts b/src/utils/connectionCatalogue.ts new file mode 100644 index 00000000..30b4f5c3 --- /dev/null +++ b/src/utils/connectionCatalogue.ts @@ -0,0 +1,176 @@ +import type { PluginManifest, RegistryPluginWithStatus } from '../types/plugins'; + +/** Flattened, UI-ready view of one driver (registry or built-in). */ +export interface CatalogueDriver { + slug: string; + name: string; + engine: string; + paradigms: string[]; + verified: boolean; + installed: boolean; + installedVersion: string | null; + latestVersion: string; + isBuiltin: boolean; + platformSupported: boolean; + downloads: number | null; + updateAvailable: boolean; + icon: string | null; + color: string | null; +} + +export function toCatalogueDriver(p: RegistryPluginWithStatus): CatalogueDriver { + const engine = p.engine && p.engine.length > 0 ? p.engine : p.id; + return { + slug: p.id, + name: p.name, + engine, + paradigms: p.paradigms ?? [], + verified: p.verified ?? false, + installed: p.installed_version != null, + installedVersion: p.installed_version ?? null, + latestVersion: p.latest_version, + isBuiltin: false, + platformSupported: p.platform_supported, + downloads: p.downloads ?? null, + updateAvailable: p.update_available, + icon: p.icon ?? null, + color: null, + }; +} + +export function builtinToCatalogueDriver( + manifest: PluginManifest, + engine: string, + paradigms: string[], +): CatalogueDriver { + return { + slug: manifest.id, + name: manifest.name, + engine, + paradigms, + verified: true, + installed: true, + installedVersion: manifest.version, + latestVersion: manifest.version, + isBuiltin: true, + platformSupported: true, + downloads: null, + updateAvailable: false, + icon: manifest.icon ?? null, + color: manifest.color ?? null, + }; +} + +export interface EngineGroup { + engine: string; + displayName: string; + primaryParadigm: string; + secondaryParadigms: string[]; + drivers: CatalogueDriver[]; + installed: boolean; + verified: boolean; + downloads: number | null; +} + +export interface CatalogueFilter { + search: string; + paradigms: string[]; + verifiedOnly: boolean; + installedOnly: boolean; +} + +export interface ParadigmFacet { + key: string; + label: string; + count: number; +} + +export interface EngineSelection { + mode: 'connect' | 'install' | 'pick-driver'; + driver?: CatalogueDriver; +} + +export function groupByEngine(drivers: CatalogueDriver[]): EngineGroup[] { + const map = new Map(); + for (const d of drivers) { + const list = map.get(d.engine) ?? []; + list.push(d); + map.set(d.engine, list); + } + const groups: EngineGroup[] = []; + for (const [engine, list] of map) { + const representative = list.find((d) => d.paradigms.length > 0) ?? list[0]; + const primaryParadigm = representative.paradigms[0] ?? 'other'; + const allParadigms = new Set(); + for (const d of list) for (const p of d.paradigms) allParadigms.add(p); + allParadigms.delete(primaryParadigm); + const downloads = list.reduce( + (acc, d) => (d.downloads == null ? acc : (acc ?? 0) + d.downloads), + null, + ); + groups.push({ + engine, + displayName: representative.name, + primaryParadigm, + secondaryParadigms: [...allParadigms], + drivers: list, + installed: list.some((d) => d.installed), + verified: list.some((d) => d.verified), + downloads, + }); + } + // Section order in the catalogue follows Map-insertion order over this list, + // so the sort here drives it. Builtin engines (MySQL, PostgreSQL, SQLite) + // come first — surfacing the SQL section first, the common case — and engines + // with no paradigm metadata ('other', mostly legacy-registry plugins) sink to + // the end so the catalogue doesn't lead with a wall of 'Other'. Everything + // else is alphabetical. + return groups.sort( + (a, b) => + Number(b.drivers.some((d) => d.isBuiltin)) - + Number(a.drivers.some((d) => d.isBuiltin)) || + Number(a.primaryParadigm === 'other') - + Number(b.primaryParadigm === 'other') || + a.displayName.localeCompare(b.displayName), + ); +} + +export function paradigmFacets(groups: EngineGroup[]): ParadigmFacet[] { + const counts = new Map(); + for (const g of groups) { + const seen = new Set([g.primaryParadigm, ...g.secondaryParadigms]); + for (const p of seen) counts.set(p, (counts.get(p) ?? 0) + 1); + } + return [...counts.entries()] + .map(([key, count]) => ({ key, label: labelForParadigm(key), count })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)); +} + +function labelForParadigm(key: string): string { + if (key === 'sql') return 'SQL'; + if (key === 'nosql') return 'NoSQL'; + return key.charAt(0).toUpperCase() + key.slice(1); +} + +export function filterCatalogue(groups: EngineGroup[], f: CatalogueFilter): EngineGroup[] { + const term = f.search.trim().toLowerCase(); + return groups.filter((g) => { + if (term && !g.displayName.toLowerCase().includes(term) && !g.engine.toLowerCase().includes(term)) { + return false; + } + if (f.paradigms.length > 0) { + const groupParadigms = new Set([g.primaryParadigm, ...g.secondaryParadigms]); + if (!f.paradigms.some((p) => groupParadigms.has(p))) return false; + } + if (f.verifiedOnly && !g.verified) return false; + if (f.installedOnly && !g.installed) return false; + return true; + }); +} + +export function resolveEngineSelection(group: EngineGroup): EngineSelection { + if (group.drivers.length === 0) return { mode: 'pick-driver' }; + if (group.drivers.length > 1) return { mode: 'pick-driver' }; + const driver = group.drivers[0]; + return driver.installed ? { mode: 'connect', driver } : { mode: 'install', driver }; +} diff --git a/tests/components/connection/ConnectionCatalogue.test.tsx b/tests/components/connection/ConnectionCatalogue.test.tsx new file mode 100644 index 00000000..baf1c538 --- /dev/null +++ b/tests/components/connection/ConnectionCatalogue.test.tsx @@ -0,0 +1,29 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ConnectionCatalogue } from '../../../src/components/modals/connection/ConnectionCatalogue'; +import type { EngineGroup, ParadigmFacet } from '../../../src/utils/connectionCatalogue'; + +const mk = (engine: string, name: string, paradigm: string, verified = false): EngineGroup => ({ + engine, displayName: name, primaryParadigm: paradigm, secondaryParadigms: [], + drivers: [{ slug: engine, name, engine, paradigms: [paradigm], verified, installed: false, installedVersion: null, latestVersion: '1', isBuiltin: false, platformSupported: true, downloads: 1, updateAvailable: false, icon: null, color: null }], + installed: false, verified, downloads: 1, +}); + +const groups = [mk('postgres', 'PostgreSQL', 'sql', true), mk('qdrant', 'Qdrant', 'vector')]; +const facets: ParadigmFacet[] = [{ key: 'sql', label: 'SQL', count: 1 }, { key: 'vector', label: 'Vector', count: 1 }]; + +describe('ConnectionCatalogue', () => { + it('filters by search text', () => { + render(); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'qdr' } }); + expect(screen.queryByText('PostgreSQL')).not.toBeInTheDocument(); + expect(screen.getByText('Qdrant')).toBeInTheDocument(); + }); + + it('filters by a paradigm chip', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /SQL/i })); + expect(screen.getByText('PostgreSQL')).toBeInTheDocument(); + expect(screen.queryByText('Qdrant')).not.toBeInTheDocument(); + }); +}); diff --git a/tests/components/connection/EngineCard.test.tsx b/tests/components/connection/EngineCard.test.tsx new file mode 100644 index 00000000..37c27a4d --- /dev/null +++ b/tests/components/connection/EngineCard.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { EngineCard } from '../../../src/components/modals/connection/EngineCard'; +import type { EngineGroup } from '../../../src/utils/connectionCatalogue'; + +const group: EngineGroup = { + engine: 'firestore', + displayName: 'Firestore', + primaryParadigm: 'document', + secondaryParadigms: ['vector'], + drivers: [ + { slug: 'fs-a', name: 'Firestore', engine: 'firestore', paradigms: ['document', 'vector'], verified: true, installed: false, installedVersion: null, latestVersion: '1.0.0', isBuiltin: false, platformSupported: true, downloads: 1200, updateAvailable: false, icon: null, color: null }, + { slug: 'fs-b', name: 'FS Alt', engine: 'firestore', paradigms: ['document'], verified: false, installed: true, installedVersion: '0.9.0', latestVersion: '0.9.0', isBuiltin: false, platformSupported: true, downloads: 30, updateAvailable: false, icon: null, color: null }, + ], + installed: true, + verified: true, + downloads: 1230, +}; + +describe('EngineCard', () => { + it('renders name, verified badge, installed badge, multi-driver count', () => { + render(); + expect(screen.getByText('Firestore')).toBeInTheDocument(); + expect(screen.getByText(/verified/i)).toBeInTheDocument(); + expect(screen.getByText(/installed/i)).toBeInTheDocument(); + expect(screen.getByText(/2 drivers/i)).toBeInTheDocument(); + }); + + it('calls onSelect with the group when clicked', () => { + const onSelect = vi.fn(); + render(); + screen.getByRole('button').click(); + expect(onSelect).toHaveBeenCalledWith(group); + }); +}); diff --git a/tests/components/connection/InstallBanner.test.tsx b/tests/components/connection/InstallBanner.test.tsx new file mode 100644 index 00000000..4c26e564 --- /dev/null +++ b/tests/components/connection/InstallBanner.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { InstallBanner } from '../../../src/components/modals/connection/InstallBanner'; + +const driver = { slug: 'qdrant', name: 'Qdrant', engine: 'qdrant', paradigms: ['vector'], verified: false, installed: false, installedVersion: null, latestVersion: '1.3.0', isBuiltin: false, platformSupported: true, downloads: 5, updateAvailable: false, icon: null, color: null }; + +describe('InstallBanner', () => { + it('shows the install button when idle', () => { + render(); + expect(screen.getByRole('button', { name: /install driver/i })).toBeInTheDocument(); + }); + + it('calls onInstall with slug + latest version', () => { + const onInstall = vi.fn(); + render(); + screen.getByRole('button', { name: /install driver/i }).click(); + expect(onInstall).toHaveBeenCalledWith('qdrant', '1.3.0'); + }); + + it('shows a retry affordance on error', () => { + render(); + expect(screen.getByText(/boom/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/connection/InstallGate.test.tsx b/tests/components/connection/InstallGate.test.tsx new file mode 100644 index 00000000..fe956758 --- /dev/null +++ b/tests/components/connection/InstallGate.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { InstallGate } from "../../../src/components/modals/connection/InstallGate"; +import type { CatalogueDriver } from "../../../src/utils/connectionCatalogue"; + +const driver = (over: Partial = {}): CatalogueDriver => ({ + slug: "firestore", + name: "Firestore", + engine: "firestore", + paradigms: ["document"], + verified: true, + installed: false, + installedVersion: null, + latestVersion: "0.3.6", + isBuiltin: false, + platformSupported: true, + downloads: 10, + updateAvailable: false, + icon: null, + color: null, + ...over, +}); + +describe("InstallGate", () => { + it("shows a solid install button with the latest version when supported", () => { + render( + , + ); + expect(screen.getByRole("button", { name: /install v0\.3\.6/i })).toBeInTheDocument(); + }); + + it("calls onInstall with slug + latest version", () => { + const onInstall = vi.fn(); + render( + , + ); + screen.getByRole("button", { name: /install v0\.3\.6/i }).click(); + expect(onInstall).toHaveBeenCalledWith("firestore", "0.3.6"); + }); + + it("shows an unavailable message and no install button when the platform is unsupported", () => { + render( + , + ); + expect(screen.getByText(/no installable release for your platform/i)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /install/i })).not.toBeInTheDocument(); + }); + + it("shows a retry label on error", () => { + render( + , + ); + expect(screen.getByText(/no 0\.3\.6 release exists/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry install/i })).toBeInTheDocument(); + }); +}); diff --git a/tests/components/modals/PluginInstallConfirmModal.test.tsx b/tests/components/modals/PluginInstallConfirmModal.test.tsx new file mode 100644 index 00000000..284e9504 --- /dev/null +++ b/tests/components/modals/PluginInstallConfirmModal.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { PluginInstallConfirmModal } from "../../../src/components/modals/PluginInstallConfirmModal"; +import type { DeepLinkInstallRequest } from "../../../src/hooks/useDeepLinkInstall"; + +const request: DeepLinkInstallRequest = { slug: "firestore", version: null, registry: null }; + +const preview = (over: Record = {}) => ({ + id: "firestore", + name: "Firestore", + description: "", + author: "", + homepage: "", + latest_version: "1.2.3", + releases: [], + installed_version: null, + update_available: false, + platform_supported: true, + install_action: "install", + ...over, +}); + +describe("PluginInstallConfirmModal", () => { + beforeEach(() => vi.mocked(invoke).mockReset()); + + it("shows the install button when not installed", async () => { + vi.mocked(invoke).mockResolvedValue(preview({ install_action: "install" })); + render( + , + ); + await waitFor(() => + expect(screen.getByRole("button", { name: /deepLink\.installConfirm/i })).toBeInTheDocument(), + ); + }); + + it("shows the update button when an update is available", async () => { + vi.mocked(invoke).mockResolvedValue( + preview({ install_action: "update", installed_version: "1.0.0" }), + ); + render( + , + ); + await waitFor(() => + expect(screen.getByRole("button", { name: /deepLink\.updateConfirm/i })).toBeInTheDocument(), + ); + }); + + it("hides the primary action and shows an info banner when up to date", async () => { + vi.mocked(invoke).mockResolvedValue( + preview({ install_action: "up_to_date", installed_version: "1.2.3" }), + ); + render( + , + ); + await waitFor(() => expect(screen.getByText(/deepLink\.alreadyInstalled/i)).toBeInTheDocument()); + expect(screen.queryByRole("button", { name: /deepLink\.installConfirm/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /deepLink\.updateConfirm/i })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts index 3ece45a0..5a610fcf 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -79,6 +79,9 @@ vi.mock("@monaco-editor/react", () => ({ // Mock lucide-react icons vi.mock("lucide-react", () => ({ + ShieldCheck: () => null, + Download: () => null, + AlertTriangle: () => null, Trash2: () => null, Edit: () => null, ArrowUp: () => null, @@ -166,14 +169,14 @@ vi.mock("lucide-react", () => ({ PanelTop: () => null, ChevronsDownUp: () => null, ChevronsUpDown: () => null, - AlertTriangle: () => null, Home: () => null, + Boxes: () => null, + CheckCircle2: () => null, // CONNECTION_ICON_PACK icons Server: () => null, HardDrive: () => null, Cloud: () => null, CloudCog: () => null, - ShieldCheck: () => null, Flame: () => null, Bug: () => null, Beaker: () => null, diff --git a/tests/utils/connectionCatalogue.test.ts b/tests/utils/connectionCatalogue.test.ts new file mode 100644 index 00000000..0b284963 --- /dev/null +++ b/tests/utils/connectionCatalogue.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from 'vitest'; +import { + toCatalogueDriver, + builtinToCatalogueDriver, + groupByEngine, + paradigmFacets, + filterCatalogue, + resolveEngineSelection, +} from '../../src/utils/connectionCatalogue'; +import type { RegistryPluginWithStatus, PluginManifest } from '../../src/types/plugins'; + +const registryPlugin = (over: Partial = {}): RegistryPluginWithStatus => ({ + id: 'firestore', + name: 'Firestore', + description: '', + author: '', + homepage: '', + latest_version: '1.2.0', + releases: [], + installed_version: null, + update_available: false, + platform_supported: true, + engine: 'firestore', + paradigms: ['document'], + verified: true, + downloads: 42, + ...over, +}); + +describe('connectionCatalogue', () => { + describe('toCatalogueDriver', () => { + it('maps a registry plugin to a catalogue driver', () => { + const d = toCatalogueDriver(registryPlugin()); + expect(d).toMatchObject({ + slug: 'firestore', + engine: 'firestore', + paradigms: ['document'], + verified: true, + installed: false, + installedVersion: null, + latestVersion: '1.2.0', + isBuiltin: false, + platformSupported: true, + downloads: 42, + }); + }); + + it('falls back to the slug as engine when engine is missing', () => { + const d = toCatalogueDriver(registryPlugin({ engine: null, paradigms: [] })); + expect(d.engine).toBe('firestore'); + expect(d.paradigms).toEqual([]); + }); + + it('marks installed when installed_version is set', () => { + const d = toCatalogueDriver(registryPlugin({ installed_version: '1.1.0' })); + expect(d.installed).toBe(true); + expect(d.installedVersion).toBe('1.1.0'); + }); + }); + + describe('builtinToCatalogueDriver', () => { + it('maps a built-in manifest as installed + verified', () => { + const manifest = { + id: 'postgres', + name: 'PostgreSQL', + version: '1.0.0', + description: '', + default_port: 5432, + is_builtin: true, + capabilities: {} as PluginManifest['capabilities'], + } as PluginManifest; + const d = builtinToCatalogueDriver(manifest, 'postgres', ['sql']); + expect(d).toMatchObject({ + slug: 'postgres', + engine: 'postgres', + paradigms: ['sql'], + verified: true, + installed: true, + isBuiltin: true, + platformSupported: true, + }); + }); + }); + + describe('groupByEngine', () => { + it('collapses drivers sharing an engine into one group', () => { + const a = toCatalogueDriver(registryPlugin({ id: 'firestore-a', engine: 'firestore', paradigms: ['document'] })); + const b = toCatalogueDriver(registryPlugin({ id: 'firestore-b', engine: 'firestore', paradigms: ['document', 'vector'], verified: false })); + const groups = groupByEngine([a, b]); + expect(groups).toHaveLength(1); + expect(groups[0].engine).toBe('firestore'); + expect(groups[0].drivers).toHaveLength(2); + expect(groups[0].verified).toBe(true); // any driver verified + expect(groups[0].primaryParadigm).toBe('document'); + expect(groups[0].secondaryParadigms).toContain('vector'); + }); + + it('uses "other" as primary paradigm when none declared', () => { + const d = toCatalogueDriver(registryPlugin({ id: 'weird', engine: 'weird', paradigms: [] })); + const groups = groupByEngine([d]); + expect(groups[0].primaryParadigm).toBe('other'); + }); + }); + + describe('paradigmFacets', () => { + it('counts engines per paradigm, multi-model counted in each', () => { + const groups = groupByEngine([ + toCatalogueDriver(registryPlugin({ id: 'pg', engine: 'postgres', paradigms: ['sql'] })), + toCatalogueDriver(registryPlugin({ id: 'surreal', engine: 'surreal', paradigms: ['document', 'graph'] })), + ]); + const facets = paradigmFacets(groups); + const byKey = Object.fromEntries(facets.map((f) => [f.key, f.count])); + expect(byKey.sql).toBe(1); + expect(byKey.document).toBe(1); + expect(byKey.graph).toBe(1); + }); + }); + + describe('filterCatalogue', () => { + const groups = () => groupByEngine([ + toCatalogueDriver(registryPlugin({ id: 'pg', name: 'PostgreSQL', engine: 'postgres', paradigms: ['sql'], verified: true, installed_version: '1.0.0' })), + toCatalogueDriver(registryPlugin({ id: 'qdrant', name: 'Qdrant', engine: 'qdrant', paradigms: ['vector'], verified: false })), + ]); + + it('matches search against name and engine', () => { + expect(filterCatalogue(groups(), { search: 'qdr', paradigms: [], verifiedOnly: false, installedOnly: false })).toHaveLength(1); + }); + + it('filters by paradigm (OR)', () => { + const r = filterCatalogue(groups(), { search: '', paradigms: ['vector'], verifiedOnly: false, installedOnly: false }); + expect(r.map((g) => g.engine)).toEqual(['qdrant']); + }); + + it('filters by verified and installed toggles', () => { + const v = filterCatalogue(groups(), { search: '', paradigms: [], verifiedOnly: true, installedOnly: false }); + expect(v.map((g) => g.engine)).toEqual(['postgres']); + const i = filterCatalogue(groups(), { search: '', paradigms: [], verifiedOnly: false, installedOnly: true }); + expect(i.map((g) => g.engine)).toEqual(['postgres']); + }); + }); + + describe('resolveEngineSelection', () => { + const grp = (drivers: ReturnType[]) => groupByEngine(drivers)[0]; + + it('connects directly when one installed driver', () => { + const g = grp([toCatalogueDriver(registryPlugin({ id: 'pg', engine: 'postgres', installed_version: '1.0.0' }))]); + expect(resolveEngineSelection(g)).toEqual({ mode: 'connect', driver: g.drivers[0] }); + }); + + it('asks to install when one driver, not installed', () => { + const g = grp([toCatalogueDriver(registryPlugin({ id: 'pg', engine: 'postgres' }))]); + expect(resolveEngineSelection(g)).toEqual({ mode: 'install', driver: g.drivers[0] }); + }); + + it('asks to pick when multiple drivers', () => { + const g = grp([ + toCatalogueDriver(registryPlugin({ id: 'fs-a', engine: 'firestore' })), + toCatalogueDriver(registryPlugin({ id: 'fs-b', engine: 'firestore' })), + ]); + expect(resolveEngineSelection(g)).toEqual({ mode: 'pick-driver' }); + }); + }); +});