feat(plugin): add plugin framework workflow

This commit is contained in:
t8y2
2026-09-13 10:39:17 +08:00
committed by GitHub
parent 3767f4a83b
commit b5072f1a3e
247 changed files with 33841 additions and 2256 deletions
+3
View File
@@ -11,6 +11,9 @@ apps/desktop/tsconfig.json text eol=lf
apps/desktop/*.ts text eol=lf
src-tauri/windows/nsis/**/*.nsi text eol=lf
# Keep plugin dev-host formatting checks consistent on Windows checkouts.
plugins/sdk/dev-host/** text=auto eol=lf
# Keep tests available for review and CI without counting them as shipped code
# in GitHub's repository language breakdown.
packages/app-tests/** linguist-vendored
+3
View File
@@ -100,6 +100,9 @@ jobs:
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Install plugin development host dependencies
run: npm ci --prefix plugins/sdk/dev-host
- name: Setup Rust
uses: dtolnay/rust-toolchain@1.97.1
+371
View File
@@ -0,0 +1,371 @@
name: Plugin CLI Release
on:
push:
tags:
- "plugin-cli-v*"
workflow_dispatch:
inputs:
version:
description: "Plugin CLI version to publish, for example 0.1.1"
required: true
default: "0.1.1"
npm-tag:
description: "npm distribution tag"
required: true
default: "latest"
type: choice
options:
- latest
- next
permissions:
attestations: write
contents: write
id-token: write
concurrency:
group: plugin-cli-release
cancel-in-progress: false
jobs:
prepare:
name: Validate plugin CLI release
runs-on: ubuntu-24.04
outputs:
tag: ${{ steps.release.outputs.tag }}
version: ${{ steps.release.outputs.version }}
npm-tag: ${{ steps.release.outputs.npm-tag }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v6
with:
go-version: "1.22.x"
cache: false
- name: Validate release branch and versions
id: release
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_NPM_TAG: ${{ inputs.npm-tag }}
run: |
node <<'NODE'
const fs = require("fs");
let version;
let tag;
let npmTag;
if (process.env.GITHUB_EVENT_NAME === "push") {
tag = process.env.GITHUB_REF_NAME;
const match = /^plugin-cli-v(.+)$/.exec(tag);
if (!match) throw new Error(`Unexpected plugin CLI tag: ${tag}`);
version = match[1];
npmTag = version.includes("-") ? "next" : "latest";
} else {
if (process.env.GITHUB_REF !== "refs/heads/main") {
throw new Error(`Manual plugin CLI releases must run from main, received ${process.env.GITHUB_REF}.`);
}
version = process.env.INPUT_VERSION.trim();
tag = `plugin-cli-v${version}`;
npmTag = process.env.INPUT_NPM_TAG;
}
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid semantic version: ${version}`);
}
if (version.includes("-") && npmTag === "latest") {
throw new Error(`Prerelease ${version} cannot use the npm latest tag.`);
}
const packagePaths = [
"packages/plugin-cli/package.json",
"packages/plugin-cli-darwin-arm64/package.json",
"packages/plugin-cli-darwin-x64/package.json",
"packages/plugin-cli-linux-arm64-gnu/package.json",
"packages/plugin-cli-linux-x64-gnu/package.json",
"packages/plugin-cli-win32-arm64/package.json",
"packages/plugin-cli-win32-x64/package.json",
];
for (const path of packagePaths) {
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
if (pkg.version !== version) {
throw new Error(`${path} has version ${pkg.version}; expected ${version}.`);
}
}
const cliCargo = fs.readFileSync("plugins/sdk/cli/Cargo.toml", "utf8");
const cliVersion = cliCargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1];
if (cliVersion !== version) {
throw new Error(`plugins/sdk/cli/Cargo.toml has version ${cliVersion}; expected ${version}.`);
}
const launcher = JSON.parse(fs.readFileSync(packagePaths[0], "utf8"));
for (const [dependency, dependencyVersion] of Object.entries(launcher.optionalDependencies ?? {})) {
if (dependencyVersion !== version) {
throw new Error(`${dependency} is pinned to ${dependencyVersion}; expected ${version}.`);
}
}
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=${tag}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `npm-tag=${npmTag}\n`);
NODE
- name: Check npm token
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
if [ -z "${NODE_AUTH_TOKEN}" ]; then
echo "::error::NPM_TOKEN secret is required to publish @dbx-app/plugin-cli."
exit 1
fi
- name: Test Rust CLI
run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
- name: Prepare and test plugin development runtime
run: |
npm ci --prefix plugins/sdk/dev-host
npm test --prefix plugins/sdk/dev-host
- name: Test npm launcher
run: npm --prefix packages/plugin-cli test
- name: Verify packed npm installation
env:
DBX_PLUGIN_CLI_VERIFY_NATIVE: "1"
run: node scripts/verify-plugin-cli-package.mjs
- name: Create immutable source tag
if: github.event_name == 'workflow_dispatch'
env:
TAG: ${{ steps.release.outputs.tag }}
run: |
git fetch origin --tags --force
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
TAG_SHA="$(git rev-list -n 1 "refs/tags/${TAG}")"
HEAD_SHA="$(git rev-parse HEAD)"
if [ "${TAG_SHA}" != "${HEAD_SHA}" ]; then
echo "::error::${TAG} already points to ${TAG_SHA}, not ${HEAD_SHA}."
exit 1
fi
echo "${TAG} already points to this commit."
else
git tag "${TAG}" HEAD
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
fi
publish-platforms:
name: Publish ${{ matrix.package-name }}
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
package-dir: plugin-cli-darwin-arm64
package-name: "@dbx-app/plugin-cli-darwin-arm64"
binary: dbx-plugin
- runner: macos-15-intel
target: x86_64-apple-darwin
package-dir: plugin-cli-darwin-x64
package-name: "@dbx-app/plugin-cli-darwin-x64"
binary: dbx-plugin
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
package-dir: plugin-cli-linux-arm64-gnu
package-name: "@dbx-app/plugin-cli-linux-arm64-gnu"
binary: dbx-plugin
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
package-dir: plugin-cli-linux-x64-gnu
package-name: "@dbx-app/plugin-cli-linux-x64-gnu"
binary: dbx-plugin
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
package-dir: plugin-cli-win32-arm64
package-name: "@dbx-app/plugin-cli-win32-arm64"
binary: dbx-plugin.exe
- runner: windows-2025
target: x86_64-pc-windows-msvc
package-dir: plugin-cli-win32-x64
package-name: "@dbx-app/plugin-cli-win32-x64"
binary: dbx-plugin.exe
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: actions/setup-python@v6
if: runner.os == 'Linux'
with:
python-version: "3.x"
- name: Install Linux compatibility builder
if: runner.os == 'Linux'
run: pip install ziglang==0.14.0 cargo-zigbuild==0.23.0
- name: Build precompiled CLI
shell: bash
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target/plugin-cli
run: |
if [[ "${{ runner.os }}" == "Linux" ]]; then
cargo zigbuild --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}.2.31"
else
cargo build --locked --release --manifest-path plugins/sdk/cli/Cargo.toml --target "${{ matrix.target }}"
fi
- name: Stage platform package
shell: bash
run: |
mkdir -p "packages/${{ matrix.package-dir }}/bin"
cp "target/plugin-cli/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
if [[ "${{ runner.os }}" != "Windows" ]]; then
chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
fi
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Publish platform package
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
run: |
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
echo "${{ matrix.package-name }}@${VERSION} already exists; refreshing ${NPM_TAG}."
npm dist-tag add "${{ matrix.package-name }}@${VERSION}" "${NPM_TAG}"
else
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance --tag "${NPM_TAG}"
fi
publish-launcher:
name: Publish @dbx-app/plugin-cli
needs: [prepare, publish-platforms]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Prepare development runtime build dependencies
run: npm ci --prefix plugins/sdk/dev-host
- name: Wait for platform packages
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
wait_for_package() {
package="$1"
for attempt in $(seq 1 60); do
if npm view "${package}@${VERSION}" version >/dev/null 2>&1; then
return 0
fi
echo "Waiting for ${package}@${VERSION} (${attempt}/60)."
sleep 10
done
return 1
}
for package in \
@dbx-app/plugin-cli-darwin-arm64 \
@dbx-app/plugin-cli-darwin-x64 \
@dbx-app/plugin-cli-linux-arm64-gnu \
@dbx-app/plugin-cli-linux-x64-gnu \
@dbx-app/plugin-cli-win32-arm64 \
@dbx-app/plugin-cli-win32-x64; do
wait_for_package "${package}" || {
echo "::error::${package}@${VERSION} is unavailable; refusing to publish the launcher."
exit 1
}
done
- name: Publish launcher and bundled SDK
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
NPM_TAG: ${{ needs.prepare.outputs.npm-tag }}
run: |
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/plugin-cli@${VERSION} already exists; refreshing ${NPM_TAG}."
npm dist-tag add "@dbx-app/plugin-cli@${VERSION}" "${NPM_TAG}"
else
npm publish ./packages/plugin-cli --access public --provenance --tag "${NPM_TAG}"
fi
verify-published-package:
name: Verify clean npm installation
needs: [prepare, publish-launcher]
runs-on: ubuntu-24.04
steps:
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v6
with:
go-version: "1.22.x"
cache: false
- name: Install from npm and package every template
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
work="$(mktemp -d)"
cd "${work}"
npm init -y >/dev/null
for attempt in $(seq 1 60); do
if npm view "@dbx-app/plugin-cli@${VERSION}" version >/dev/null 2>&1; then
break
fi
if [ "${attempt}" -eq 60 ]; then
echo "::error::@dbx-app/plugin-cli@${VERSION} did not become visible on npm."
exit 1
fi
sleep 10
done
npm install --ignore-scripts --no-audit --no-fund "@dbx-app/plugin-cli@${VERSION}"
./node_modules/.bin/dbx-plugin --version
for template in frontend rust go; do
./node_modules/.bin/dbx-plugin create "${template}-plugin" \
--template "${template}" \
--yes \
--id "com.example.npm-${template}" \
--name "npm ${template} smoke" \
--publisher example \
--description "Published npm package smoke"
./node_modules/.bin/dbx-plugin package "${template}-plugin"
test "$(find "${template}-plugin/dist" -maxdepth 1 -name '*.dbxp' | wc -l)" -eq 1
done
+39
View File
@@ -0,0 +1,39 @@
name: Plugin development host
on:
pull_request:
paths:
- '.gitattributes'
- 'plugins/sdk/cli/**'
- 'plugins/sdk/dev-host/**'
- 'packages/plugin-cli/**'
- 'scripts/verify-plugin-cli-package.mjs'
- '.github/workflows/plugin-dev-host.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: false
- run: npm ci --prefix plugins/sdk/dev-host
- run: npm run lint --prefix plugins/sdk/dev-host
- run: npm run fmt:check --prefix plugins/sdk/dev-host
- run: npm test --prefix plugins/sdk/dev-host
- run: cargo test --locked --manifest-path plugins/sdk/cli/Cargo.toml
- run: npm test --prefix packages/plugin-cli
- run: node scripts/verify-plugin-cli-package.mjs
env:
DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'
@@ -0,0 +1,218 @@
name: Reusable DBX plugin release
on:
workflow_call:
inputs:
release-tag:
description: Existing GitHub Release tag that receives the plugin assets
required: true
type: string
package-command:
description: Command that builds one unsigned candidate .dbxp and one .artifact.json for DBX_PLUGIN_TARGET
required: true
type: string
working-directory:
description: Plugin repository directory containing the package command
default: .
required: false
type: string
package-path:
description: Glob relative to working-directory for built .dbxp files
default: dist/*.dbxp
required: false
type: string
metadata-path:
description: Glob relative to working-directory for built .artifact.json files
default: dist/*.artifact.json
required: false
type: string
node-version:
default: "22"
required: false
type: string
go-version:
default: "1.22.x"
required: false
type: string
rust-toolchain:
default: stable
required: false
type: string
sdk-ref:
description: DBX tag or commit used only when installing the plugin CLI from source
default: plugin-sdk-v1
required: false
type: string
plugin-cli-version:
description: Published @dbx-app/plugin-cli version used to package the plugin
default: "0.1.1"
required: false
type: string
install-plugin-cli:
description: Install the dbx-plugin CLI before packaging
default: true
required: false
type: boolean
install-plugin-cli-from-source:
description: Build the CLI from sdk-ref instead of installing its precompiled npm package
default: false
required: false
type: boolean
build-matrix:
description: JSON matrix with runner and DBX target pairs; use one universal entry for platform-independent plugins
default: >-
{"include":[{"runner":"ubuntu-24.04","target":"linux-x64"},{"runner":"ubuntu-24.04-arm","target":"linux-arm64"},{"runner":"windows-2022","target":"windows-x64"},{"runner":"macos-15-intel","target":"darwin-x64"},{"runner":"macos-15","target":"darwin-arm64"}]}
required: false
type: string
permissions:
contents: read
jobs:
build:
name: Build ${{ matrix.target }}
strategy:
fail-fast: false
matrix: ${{ fromJSON(inputs.build-matrix) }}
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.rust-toolchain }}
- name: Checkout DBX plugin SDK
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
uses: actions/checkout@v4
with:
repository: t8y2/dbx
ref: ${{ inputs.sdk-ref }}
path: .dbx-plugin-sdk
- name: Install precompiled DBX plugin CLI
if: inputs.install-plugin-cli && !inputs.install-plugin-cli-from-source
shell: bash
run: npm install --global "@dbx-app/plugin-cli@${{ inputs.plugin-cli-version }}"
- name: Install DBX plugin CLI from source
if: inputs.install-plugin-cli && inputs.install-plugin-cli-from-source
shell: bash
run: cargo install --locked --path .dbx-plugin-sdk/plugins/sdk/cli
- name: Build unsigned plugin candidate
shell: bash
working-directory: ${{ inputs.working-directory }}
env:
DBX_PLUGIN_TARGET: ${{ matrix.target }}
DBX_PLUGIN_SDK_ROOT: ${{ inputs.install-plugin-cli-from-source && format('{0}/.dbx-plugin-sdk', github.workspace) || '' }}
run: ${{ inputs.package-command }}
- name: Upload target artifacts
uses: actions/upload-artifact@v4
with:
name: dbx-plugin-${{ matrix.target }}
if-no-files-found: error
retention-days: 1
path: |
${{ inputs.working-directory }}/${{ inputs.package-path }}
${{ inputs.working-directory }}/${{ inputs.metadata-path }}
publish:
name: Publish plugin release assets
needs: build
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download target artifacts
uses: actions/download-artifact@v4
with:
pattern: dbx-plugin-*
path: release-assets
merge-multiple: true
- name: Validate and merge candidate metadata
shell: bash
run: |
node <<'NODE'
const childProcess = require("node:child_process");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
function filesUnder(root) {
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const current = path.join(root, entry.name);
return entry.isDirectory() ? filesUnder(current) : [current];
});
}
const files = filesUnder("release-assets");
const metadataFiles = files.filter((file) => file.endsWith(".artifact.json"));
const packages = new Map();
for (const file of files.filter((file) => file.endsWith(".dbxp"))) {
const name = path.basename(file);
if (packages.has(name)) throw new Error(`Duplicate package filename ${name}`);
packages.set(name, file);
}
if (metadataFiles.length === 0 || packages.size === 0) throw new Error("Release produced no DBX plugin artifacts");
const targets = new Set();
let pluginIdentity;
const artifacts = metadataFiles.map((file) => {
const artifact = JSON.parse(fs.readFileSync(file, "utf8"));
if (!/^[a-z0-9-]{1,64}$/.test(artifact.target || "")) throw new Error(`Invalid artifact target in ${file}`);
if (!/^[a-fA-F0-9]{64}$/.test(artifact.sha256 || "")) throw new Error(`Invalid artifact SHA-256 in ${file}`);
if (artifact.signingKeyId !== undefined) throw new Error(`Candidate metadata ${file} must not declare signingKeyId`);
if (!Number.isSafeInteger(artifact.size) || artifact.size < 0) throw new Error(`Invalid artifact size in ${file}`);
const packageName = path.basename(new URL(artifact.url, "https://plugins.invalid/releases/").pathname);
const packageFile = packages.get(packageName);
if (!packageFile) throw new Error(`Artifact metadata ${file} references missing package ${packageName}`);
const packageBytes = fs.readFileSync(packageFile);
const packageSha256 = crypto.createHash("sha256").update(packageBytes).digest("hex");
if (artifact.size !== packageBytes.length) throw new Error(`Artifact metadata ${file} has the wrong package size`);
if (artifact.sha256.toLowerCase() !== packageSha256) throw new Error(`Artifact metadata ${file} has the wrong package SHA-256`);
const entries = childProcess.execFileSync("unzip", ["-Z1", packageFile], { encoding: "utf8" }).split(/\r?\n/);
if (entries.includes("signature.json")) throw new Error(`Candidate package ${packageName} must be unsigned`);
const manifest = JSON.parse(childProcess.execFileSync("unzip", ["-p", packageFile, "manifest.json"], { encoding: "utf8" }));
const identity = {
id: manifest.id,
name: manifest.name,
description: manifest.description || "",
publisher: manifest.publisher,
version: manifest.version,
permissions: [...(manifest.permissions || [])].sort(),
};
if (!identity.id || !identity.name || !identity.publisher || !identity.version) throw new Error(`Package ${packageName} has incomplete manifest identity`);
if (pluginIdentity && JSON.stringify(pluginIdentity) !== JSON.stringify(identity)) throw new Error(`Package ${packageName} manifest identity differs from other targets`);
pluginIdentity ||= identity;
if (targets.has(artifact.target)) throw new Error(`Duplicate artifact target ${artifact.target}`);
targets.add(artifact.target);
return artifact;
}).sort((left, right) => left.target.localeCompare(right.target));
fs.writeFileSync("release-assets/release-candidates.json", `${JSON.stringify({ plugin: pluginIdentity, artifacts }, null, 2)}\n`);
NODE
- name: Upload assets to GitHub Release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.release-tag }}
run: |
mapfile -d '' packages < <(find release-assets -type f -name '*.dbxp' -print0)
if [ "${#packages[@]}" -eq 0 ]; then
echo "No .dbxp packages were downloaded" >&2
exit 1
fi
gh release upload "$RELEASE_TAG" "${packages[@]}" release-assets/release-candidates.json \
--repo "$GITHUB_REPOSITORY" \
--clobber
+3
View File
@@ -32,6 +32,7 @@ plugins/jdbc/build/
plugins/jdbc/bin/main/
plugins/jdbc/bin/test/
plugins/jdbc/dist/
packages/plugin-cli/sdk-root/
docs/.next/
docs/out/
docs/.source/
@@ -65,6 +66,7 @@ Thumbs.db
.env
.env.*
!.env.example
.dbx-plugin-signing-key.env
# Temp
tmp/
@@ -81,6 +83,7 @@ review_classified.json
# Logs
*.log
/.dbx-dev/
# Generated changelog data
+1
View File
@@ -65,6 +65,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl
| `crates/dbx-web/` | Docker / Web HTTP backend |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| `packages/plugin-cli/` | Precompiled `@dbx-app/plugin-cli` launcher and bundled plugin SDKs |
| `packages/mongo-shell/` | Private MongoDB editor parsing helpers |
| `docs/` | Official documentation site |
| `examples/` | Sample configs and automation scripts |
Generated
+70 -13
View File
@@ -1916,6 +1916,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@@ -2092,9 +2093,11 @@ dependencies = [
"csv",
"dbx-sqlite-worker",
"deadpool-postgres",
"ed25519-dalek 2.2.0",
"encoding_rs",
"flate2",
"font-kit",
"fs2",
"futures",
"httpdate",
"iana-time-zone",
@@ -2126,6 +2129,7 @@ dependencies = [
"rust_decimal",
"rustls 0.23.43",
"rustls-pemfile 2.2.0",
"semver",
"serde",
"serde_json",
"serde_yaml",
@@ -2587,19 +2591,43 @@ dependencies = [
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0-rc.11",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@@ -2609,11 +2637,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"ed25519 3.0.0-rc.4",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -2638,7 +2666,7 @@ dependencies = [
"hybrid-array",
"once_cell",
"pem-rfc7468 1.0.0",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"rustcrypto-ff",
"rustcrypto-group",
@@ -3035,6 +3063,16 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -4210,7 +4248,7 @@ dependencies = [
"bcrypt-pbkdf",
"crypto-bigint",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"hex",
"hmac 0.13.0",
"num-bigint-dig",
@@ -4222,7 +4260,7 @@ dependencies = [
"sec1",
"sha1 0.11.0",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"ssh-cipher",
"ssh-encoding",
"subtle",
@@ -6097,6 +6135,16 @@ dependencies = [
"spki 0.8.0-rc.4",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0-rc.11"
@@ -7053,10 +7101,10 @@ dependencies = [
"crypto-primes",
"digest 0.11.3",
"pkcs1",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
@@ -7121,7 +7169,7 @@ dependencies = [
"der 0.8.1",
"digest 0.10.7",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"elliptic-curve",
"enum_dispatch",
"flate2",
@@ -7150,7 +7198,7 @@ dependencies = [
"pbkdf2 0.13.0",
"pkcs1",
"pkcs5",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"polyval 0.7.3",
"rand 0.10.2",
"rand_core 0.10.1",
@@ -7165,7 +7213,7 @@ dependencies = [
"sha2 0.10.9",
"sha2 0.11.0",
"sha3",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"ssh-encoding",
"subtle",
@@ -8034,6 +8082,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
+3
View File
@@ -458,6 +458,8 @@ make docs
The official DBX documentation site lives in `docs/`. If you want to improve the website content or documentation pages, edit the files under `docs/` and run `make docs` to preview the site locally.
Plugin authors should start with [Develop and Submit DBX Plugins](https://dbxio.com/en/docs/plugin-development). Plugin source normally stays in its own repository; Marketplace listing pull requests go to [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store), while plugin host, SDK, and CLI changes go to this repository.
For clean, reproducible local database instances, use the versioned Docker Compose recipes under [`deploy/database/`](deploy/database/README.md):
```bash
@@ -495,6 +497,7 @@ The installer will be in `src-tauri/target/release/bundle/`.
## Documentation
- [Official docs](https://dbxio.com/en/docs/what-is-dbx) — feature guides and tutorials
- [Plugin development](https://dbxio.com/en/docs/plugin-development) — create plugins and submit Marketplace listings to the correct repository
- [Database Test Lab](https://dbxio.com/en/docs/database-lab) — local database recipes for development and verification
- [Contributing](CONTRIBUTING.md) — how to pick up issues and open PRs
- [Web API reference](docs/content/docs/web-api.mdx) — HTTP API for Docker/Web deployments
+3
View File
@@ -26,5 +26,8 @@ Security-sensitive areas include:
- AI provider keys and OpenAI-compatible endpoint configuration.
- MCP and CLI access to local DBX connections.
- Docker web service authentication and data directory handling.
- Plugin package verification, native sidecar execution, sandboxed plugin UI, host bridge permissions, and plugin connection secrets.
Plugin connection secrets are stored outside ordinary connection JSON. Unencrypted sync snapshots are always redacted. If encrypted secret sync is enabled with a user-provided passphrase, plugin secrets are included only in the encrypted payload and are restored through the normal secret-store path.
Please avoid testing against systems you do not own or have explicit permission to assess.
+72 -15
View File
@@ -1154,6 +1154,7 @@ dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto 0.2.9",
"rustc_version",
"subtle",
@@ -1245,8 +1246,10 @@ dependencies = [
"csv",
"dbx-sqlite-worker",
"deadpool-postgres",
"ed25519-dalek 2.2.0",
"encoding_rs",
"flate2",
"fs2",
"futures",
"httpdate",
"iana-time-zone",
@@ -1275,6 +1278,7 @@ dependencies = [
"rust_decimal",
"rustls 0.23.42",
"rustls-pemfile 2.2.0",
"semver",
"serde",
"serde_json",
"serde_yaml",
@@ -1551,19 +1555,43 @@ dependencies = [
"digest 0.11.3",
"elliptic-curve",
"rfc6979",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8 0.10.2",
"signature 2.2.0",
]
[[package]]
name = "ed25519"
version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"pkcs8",
"signature",
"pkcs8 0.11.0-rc.11",
"signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek 4.1.3",
"ed25519 2.2.3",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]]
@@ -1573,11 +1601,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053618a4c3d3bc24f188aa660ae75a46eeab74ef07fb415c61431e5e7cd4749b"
dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"ed25519 3.0.0-rc.4",
"rand_core 0.10.1",
"serde",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"subtle",
"zeroize",
]
@@ -1602,7 +1630,7 @@ dependencies = [
"hybrid-array",
"once_cell",
"pem-rfc7468 1.0.0",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"rustcrypto-ff",
"rustcrypto-group",
@@ -1799,6 +1827,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
@@ -2321,7 +2359,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.62.2",
]
[[package]]
@@ -2526,7 +2564,7 @@ dependencies = [
"bcrypt-pbkdf",
"crypto-bigint",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"hex",
"hmac 0.13.0",
"num-bigint-dig",
@@ -2538,7 +2576,7 @@ dependencies = [
"sec1",
"sha1 0.11.0",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"ssh-cipher",
"ssh-encoding",
"subtle",
@@ -3729,6 +3767,16 @@ dependencies = [
"spki 0.8.0-rc.4",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der 0.7.10",
"spki 0.7.3",
]
[[package]]
name = "pkcs8"
version = "0.11.0-rc.11"
@@ -4399,10 +4447,10 @@ dependencies = [
"crypto-primes",
"digest 0.11.3",
"pkcs1",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"rand_core 0.10.1",
"sha2 0.11.0",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"zeroize",
]
@@ -4448,7 +4496,7 @@ dependencies = [
"der 0.8.1",
"digest 0.10.7",
"ecdsa",
"ed25519-dalek",
"ed25519-dalek 3.0.0-pre.6",
"elliptic-curve",
"enum_dispatch",
"flate2",
@@ -4477,7 +4525,7 @@ dependencies = [
"pbkdf2 0.13.0",
"pkcs1",
"pkcs5",
"pkcs8",
"pkcs8 0.11.0-rc.11",
"polyval 0.7.3",
"rand 0.10.2",
"rand_core 0.10.1",
@@ -4492,7 +4540,7 @@ dependencies = [
"sha2 0.10.9",
"sha2 0.11.0",
"sha3",
"signature",
"signature 3.0.0",
"spki 0.8.0-rc.4",
"ssh-encoding",
"subtle",
@@ -5063,6 +5111,15 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "signature"
version = "3.0.0"
@@ -5415,7 +5472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
+70 -13
View File
@@ -70,6 +70,7 @@ import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
@@ -155,6 +156,7 @@ const QueryHistory = defineAsyncComponent(() => import("@/components/editor/Quer
const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue"));
const SqlFilePanel = defineAsyncComponent(() => import("@/components/layout/SqlFilePanel.vue"));
const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue"));
const PluginCenterPage = defineAsyncComponent(() => import("@/components/plugins/PluginContributionsPanel.vue"));
const EditorSettingsPage = defineAsyncComponent(() => import("@/components/editor/EditorSettingsDialog.vue"));
const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue"));
const CloseActionPromptDialog = defineAsyncComponent(() => import("@/components/layout/CloseActionPromptDialog.vue"));
@@ -323,8 +325,13 @@ const showQueryEditorObjectSourceDialog = ref(false);
const driverStoreTabOpen = ref(false);
const driverStoreActive = ref(false);
const driverStoreActiveTab = ref<"agent" | "jdbc" | "storage" | "runtime">("agent");
const settingsReturnSurface = ref<"query" | "driverStore" | "welcome">("welcome");
const pluginCenterTabOpen = ref(false);
const pluginCenterActive = ref(false);
const pluginCenterFocus = ref<PluginCenterFocus | null>(null);
const connectionPluginProvider = ref<PluginCenterFocus | null>(null);
const settingsReturnSurface = ref<"query" | "driverStore" | "pluginCenter" | "welcome">("welcome");
const showDriverStore = computed(() => driverStoreTabOpen.value && driverStoreActive.value);
const showPluginCenter = computed(() => pluginCenterTabOpen.value && pluginCenterActive.value);
const showSettingsPage = computed(() => Boolean(settingsPageTabOpen.value && settingsStore.settingsPageActive));
const showQuickOpen = ref(false);
const showTabSwitcher = ref(false);
@@ -863,7 +870,7 @@ const specialPageTabs = computed(() => ({
driverStoreActive: driverStoreActive.value,
driverUpdateCount: toolbarAgentDriverUpdateCount.value,
}));
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || settingsStore.settingsPageActive))));
provide(GROUP_TAB_BAR_PORTAL, createGroupTabBarPortal(computed(() => !isDetachedWindowContext && (driverStoreActive.value || pluginCenterActive.value || settingsStore.settingsPageActive))));
provide(EDITOR_TOOLBAR_ACTIONS, {
explainMode,
blockDangerousRedisCommands,
@@ -976,6 +983,7 @@ const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({
openDbFilePath,
openConnectionDeepLink,
openAiConfigDeepLink,
closeActiveSurface,
});
const { showCloseActionPrompt, chooseQuit, chooseMinimize, cancelCloseActionPrompt, performCloseAction, setupCloseActionPromptListener, cleanupCloseActionPromptListener } = useCloseActionPrompt({ requestClose: requestAppClose });
useVisibilityChange();
@@ -1005,16 +1013,17 @@ function openSettings(initialTab = "appearance", initialSection?: string) {
settingsInitialSection.value = initialSection;
settingsNavigationRequestId.value += 1;
if (!settingsStore.settingsPageActive) {
settingsReturnSurface.value = showDriverStore.value ? "driverStore" : activeTab.value ? "query" : "welcome";
settingsReturnSurface.value = showDriverStore.value ? "driverStore" : showPluginCenter.value ? "pluginCenter" : activeTab.value ? "query" : "welcome";
}
activateSettingsPage();
}
type MainContentSurface = "query" | "settings" | "driverStore";
type MainContentSurface = "query" | "settings" | "driverStore" | "pluginCenter";
function activateMainContentSurface(surface: MainContentSurface) {
settingsStore.settingsPageActive = surface === "settings";
driverStoreActive.value = surface === "driverStore";
pluginCenterActive.value = surface === "pluginCenter";
}
watch(
@@ -1051,6 +1060,10 @@ function closeSettingsPage() {
activateMainContentSurface("driverStore");
return;
}
if (settingsReturnSurface.value === "pluginCenter" && pluginCenterTabOpen.value) {
activateMainContentSurface("pluginCenter");
return;
}
activateMainContentSurface("query");
}
@@ -1076,6 +1089,23 @@ function closeDriverStorePage() {
driverStoreActiveTab.value = "agent";
driverStoreFocus.value = null;
}
function openPluginCenterPage(focus?: PluginCenterFocus | null) {
pluginCenterFocus.value = focus ?? null;
pluginCenterTabOpen.value = true;
activateMainContentSurface("pluginCenter");
}
function closePluginCenterPage() {
pluginCenterTabOpen.value = false;
pluginCenterFocus.value = null;
activateMainContentSurface("query");
}
function openPluginConnectionDialog(pluginId: string, providerId: string) {
connectionPluginProvider.value = { pluginId, providerId };
showConnectionDialog.value = true;
}
const toolbarAgentDriverUpdateCount = computed(() => (updateNotificationsEnabled.value ? agentDriverUpdateCount.value : 0));
const toolbarHasUpdateAvailable = computed(() => updateNotificationsEnabled.value && hasUpdateAvailable.value);
const toolbarMcpUpdateAvailable = computed(() => updateNotificationsEnabled.value && mcpUpdateAvailable.value);
@@ -2299,6 +2329,7 @@ function setConnectionDialogOpen(value: boolean) {
showConnectionDialog.value = value;
if (!value) {
connectionDialogPrefill.value = null;
connectionPluginProvider.value = null;
connectionDialogInitialTab.value = undefined;
}
}
@@ -2407,6 +2438,19 @@ async function openConnectionQuery(connectionId: string) {
}
return;
}
if (initialTarget.kind === "plugin-workbench") {
try {
await queryStore.openPluginConnection(connectionId);
} catch (e: any) {
toast(
t("connection.connectFailed", {
message: translateBackendError(t, e),
}),
5000,
);
}
return;
}
if (initialTarget.kind === "etcd" || initialTarget.kind === "zookeeper" || initialTarget.kind === "consul") {
try {
await connectionStore.ensureConnected(connectionId);
@@ -3072,6 +3116,18 @@ function handleNativeSelectAll(e: KeyboardEvent) {
if (shouldBlockAppNativeSelectAll(e)) e.preventDefault();
}
async function closeActiveSurface() {
if (showSettingsPage.value) {
closeSettingsPage();
} else if (showPluginCenter.value) {
closePluginCenterPage();
} else if (showDriverStore.value) {
closeDriverStorePage();
} else if (queryStore.activeTabId) {
if (await queryStore.clearQueryResults(queryStore.activeTabId)) return;
queryStore.closeTab(queryStore.activeTabId);
}
}
async function handleKeydown(e: KeyboardEvent) {
if (e.defaultPrevented) return;
@@ -3187,14 +3243,7 @@ async function handleKeydown(e: KeyboardEvent) {
}
if (isCloseTabShortcut(e, shortcuts)) {
e.preventDefault();
if (showSettingsPage.value) {
closeSettingsPage();
} else if (showDriverStore.value) {
closeDriverStorePage();
} else if (queryStore.activeTabId) {
if (await queryStore.clearQueryResults(queryStore.activeTabId)) return;
queryStore.closeTab(queryStore.activeTabId);
}
await closeActiveSurface();
return;
}
if (isSaveShortcut(e, shortcuts) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) {
@@ -3522,6 +3571,7 @@ onUnmounted(() => {
:sql-library-save-feedback-id="sqlLibrarySaveFeedbackId"
:show-sql-file-panel="showSqlFilePanel"
:show-driver-store="showDriverStore"
:show-plugin-center="showPluginCenter"
:show-settings-page="showSettingsPage"
:checking-updates="checkingUpdates"
:has-update-available="toolbarHasUpdateAvailable"
@@ -3545,6 +3595,7 @@ onUnmounted(() => {
@open-github="openGitHub"
@open-settings="openSettings(toolbarMcpUpdateAvailable ? 'mcp' : 'appearance')"
@open-driver-store="openDriverStorePage"
@open-plugin-center="openPluginCenterPage()"
@check-updates="checkUpdates()"
@open-transfer="dialogs.showTransferDialog.value = true"
@open-sql-file="dialogs.showSqlFileDialog.value = true"
@@ -3574,6 +3625,8 @@ onUnmounted(() => {
ref="appTabBarRef"
:driver-store-open="driverStoreTabOpen"
:driver-store-active="driverStoreActive"
:plugin-center-open="pluginCenterTabOpen"
:plugin-center-active="pluginCenterActive"
:settings-page-open="settingsPageTabOpen"
:settings-page-active="settingsStore.settingsPageActive"
:agent-driver-update-count="toolbarAgentDriverUpdateCount"
@@ -3582,9 +3635,11 @@ onUnmounted(() => {
:tab-bar-width="tabBarWidth"
:tab-bar-collapsed="tabBarCollapsed"
@activate-driver-store="openDriverStorePage"
@activate-plugin-center="openPluginCenterPage(pluginCenterFocus)"
@activate-settings-page="activateSettingsPage"
@activate-tab="activateQueryTab"
@close-driver-store="closeDriverStorePage"
@close-plugin-center="closePluginCenterPage"
@close-settings-page="closeSettingsPage"
@save-tab="handleSaveTab"
@discard-tab-close="handleDiscardPendingTabClose"
@@ -3594,6 +3649,7 @@ onUnmounted(() => {
@detach-tab="detachTab"
>
<DriverStorePage v-if="driverStoreTabOpen" v-show="driverStoreActive" v-model:active-tab="driverStoreActiveTab" class="flex-1 min-h-0" :update-notifications-enabled="updateNotificationsEnabled" :focus-target="driverStoreFocus" @update-count-change="updateAgentDriverUpdateCount" />
<PluginCenterPage v-if="pluginCenterTabOpen" v-show="pluginCenterActive" class="flex-1 min-h-0" :focus-target="pluginCenterFocus" @new-connection="openPluginConnectionDialog" />
<EditorSettingsPage
v-if="settingsPageTabOpen"
v-show="settingsStore.settingsPageActive"
@@ -3635,7 +3691,7 @@ onUnmounted(() => {
</DialogFooter>
</DialogContent>
</Dialog>
<div v-show="!driverStoreActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
<div v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive" class="flex min-h-0 min-w-0 flex-1 flex-col">
<div class="flex flex-col flex-1 min-h-0">
<SqlEditorWorkspace
ref="contentAreaRef"
@@ -3845,6 +3901,7 @@ onUnmounted(() => {
<AppDialogs
:show-connection-dialog="showConnectionDialog"
:connection-prefill="connectionDialogPrefill"
:connection-plugin-provider="connectionPluginProvider"
:connection-initial-tab="connectionDialogInitialTab"
:show-danger-dialog="showDangerDialog"
:danger-sql="dangerSql"
@@ -34,7 +34,7 @@ import {
type DriverInstallProgress,
} from "@/lib/connection/driverInstallProgressUi";
import { installRegisteredManagedJdbcDriver, isManagedJdbcDriver, managedJdbcDriverRows, uninstallRegisteredManagedJdbcDriver } from "@/lib/database/managedJdbcDrivers";
import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import type { DriverStoreFocus, DriverStoreTab } from "@/lib/connection/agentDriverInstallHint";
import { isOfflineDriverPackage, webDriverImportAccept } from "@/lib/driverStore/driverImportSelection";
import { translateBackendError } from "@/i18n/backend-errors";
import { runAgentOfflineExportAction } from "@/lib/driverStore/agentOfflineExportFlow";
@@ -55,7 +55,7 @@ function backendError(e: unknown): string {
const props = withDefaults(
defineProps<{
updateNotificationsEnabled?: boolean;
activeTab?: "agent" | "jdbc" | "storage" | "runtime";
activeTab?: DriverStoreTab;
focusTarget?: DriverStoreFocus | null;
}>(),
{
@@ -67,12 +67,12 @@ const props = withDefaults(
const emit = defineEmits<{
"update-count-change": [count: number];
"update:activeTab": [tab: "agent" | "jdbc" | "storage" | "runtime"];
"update:activeTab": [tab: DriverStoreTab];
}>();
const driverStoreTab = computed({
get: () => props.activeTab,
set: (tab: "agent" | "jdbc" | "storage" | "runtime") => emit("update:activeTab", tab),
set: (tab: DriverStoreTab) => emit("update:activeTab", tab),
});
// ──────────── Driver store path ────────────
File diff suppressed because it is too large Load Diff
@@ -32,10 +32,12 @@ import type { DriverStoreFocus } from "@/lib/connection/agentDriverInstallHint";
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
import type { ConfigTab } from "@/components/connection/ConnectionDialog.vue";
import type { DatabaseType } from "@/types/database";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
const props = defineProps<{
showConnectionDialog: boolean;
connectionPrefill?: ConnectionDeepLinkDraft | null;
connectionPluginProvider?: PluginCenterFocus | null;
connectionInitialTab?: ConfigTab;
showDangerDialog: boolean;
dangerSql: string;
@@ -140,7 +142,8 @@ const editConfig = computed(() => {
const shouldShowConnectionDialog = computed(() => props.showConnectionDialog || !!editConfig.value);
watch(editConfig, (v) => {
if (v) emit("update:showConnectionDialog", true);
if (!v) return;
emit("update:showConnectionDialog", true);
});
watch(
@@ -167,6 +170,7 @@ watch(
:open="shouldShowConnectionDialog"
:edit-config="editConfig"
:prefill-config="connectionPrefill"
:plugin-provider="connectionPluginProvider"
:initial-tab="connectionInitialTab"
@update:open="emit('update:showConnectionDialog', $event)"
@connect-started="emit('connectStarted', $event)"
@@ -14,6 +14,8 @@ import "./appTabBar.css";
const props = defineProps<{
driverStoreOpen?: boolean;
driverStoreActive?: boolean;
pluginCenterOpen?: boolean;
pluginCenterActive?: boolean;
settingsPageOpen?: boolean;
settingsPageActive?: boolean;
agentDriverUpdateCount?: number;
@@ -26,6 +28,8 @@ const props = defineProps<{
const emit = defineEmits<{
"activate-driver-store": [];
"close-driver-store": [];
"activate-plugin-center": [];
"close-plugin-center": [];
"activate-settings-page": [];
"close-settings-page": [];
"activate-tab": [tabId: string];
@@ -121,7 +125,7 @@ watch(
},
);
type SpecialRegularSurface = "driverStore" | "settings";
type SpecialRegularSurface = "driverStore" | "pluginCenter" | "settings";
function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
if (keep !== "driverStore" && props.driverStoreOpen) {
@@ -130,6 +134,9 @@ function closeSpecialRegularSurfaces(keep?: SpecialRegularSurface) {
if (keep !== "settings" && props.settingsPageOpen) {
emit("close-settings-page");
}
if (keep !== "pluginCenter" && props.pluginCenterOpen) {
emit("close-plugin-center");
}
}
// Regular tabs live in editor groups now, so "close other tabs" at App level
@@ -143,6 +150,10 @@ function closeOtherActiveTabs() {
closeSpecialRegularSurfaces("driverStore");
return;
}
if (props.pluginCenterActive) {
closeSpecialRegularSurfaces("pluginCenter");
return;
}
const activeTabId = queryStore.activeTabId;
if (!activeTabId) {
@@ -188,9 +199,12 @@ function handleCancelClose() {
<template>
<!-- Targets remain mounted while inactive so the original group bars can
move here without losing their local presentation state. -->
<div v-show="driverStoreActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
<div v-show="driverStoreActive || pluginCenterActive || settingsPageActive" data-special-page-workspace class="flex min-h-0 min-w-0 flex-1 overflow-hidden" :class="layoutClass">
<div data-special-page-navigation class="flex min-h-0 min-w-0 shrink-0 flex-col overflow-auto" :style="navigationStyle">
<div v-for="group in queryStore.groups" :key="group.id" :ref="(element) => setTabBarTarget(group.id, element)" :data-special-page-tab-target="group.id" class="flex min-h-0 min-w-0" :class="isVerticalLayout ? 'flex-1' : 'shrink-0'" />
<button v-if="pluginCenterOpen" type="button" data-plugin-center-tab class="shrink-0 rounded-md px-2 py-1 text-left text-sm" :class="pluginCenterActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'" @click="emit('activate-plugin-center')">
{{ t("toolbar.pluginCenter") }}
</button>
</div>
<div data-special-page-content class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<slot />
@@ -2,7 +2,7 @@
import { computed, ref, onMounted, onBeforeUnmount, h, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import { invoke } from "@tauri-apps/api/core";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, FileDown, FolderTree } from "@lucide/vue";
import { ChevronsRight, DatabaseZap, FilePlus2, Moon, Sun, SunMoon, History, Bot, ArrowLeftRight, FileCode, BookMarked, GitCompareArrows, TableProperties, Settings, CloudDownload, Package, PlugZap, FileDown, FolderTree } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import LightDropdown from "@/components/ui/LightDropdown.vue";
@@ -38,6 +38,7 @@ const props = defineProps<{
sqlLibrarySaveFeedbackId: number;
showSqlFilePanel: boolean;
showDriverStore: boolean;
showPluginCenter: boolean;
showSettingsPage: boolean;
checkingUpdates: boolean;
updateVersion?: string;
@@ -64,6 +65,7 @@ const emit = defineEmits<{
"open-github": [];
"open-settings": [];
"open-driver-store": [];
"open-plugin-center": [];
"check-updates": [];
"open-transfer": [];
"open-sql-file": [];
@@ -438,6 +440,9 @@ const moreItems = computed(() => {
disabled: false,
});
}
if (!toolbarItems.value.pluginCenter) {
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
}
// "More" menu items (individually toggleable)
if (toolbarItems.value.sqlFile) {
@@ -504,6 +509,9 @@ const collapsedItems = computed(() => {
disabled: false,
});
}
if (toolbarItems.value.pluginCenter) {
items.push({ value: "plugin-center", label: t("toolbar.pluginCenter"), icon: PlugZap, action: () => emit("open-plugin-center"), disabled: false });
}
// Always include moreItems (may contain hidden left-side items + overflowed right items)
if (moreItems.value.length > 0) {
items.push(...moreItems.value);
@@ -571,6 +579,10 @@ const toolbarStyle = computed(() => {
<!-- 小圆点仅提示"有可更新驱动",具体数量交给对话框内标签页红点展示,避免工具栏长期挂红数字。 -->
<span v-if="agentDriverUpdateCount > 0" class="ml-0.5 inline-block h-2 w-2 rounded-full bg-red-500" :aria-label="t('toolbar.updatableDriverCount')" :title="t('toolbar.updatableDriverCount')" />
</Button>
<Button v-if="toolbarItems.pluginCenter" variant="ghost" size="sm" :class="[toolbarTextButtonClass, { 'bg-accent': showPluginCenter }]" @click="emit('open-plugin-center')">
<PlugZap class="h-3.5 w-3.5" />
<span :class="toolbarTextLabelClass">{{ t("toolbar.pluginCenter") }}</span>
</Button>
<LightDropdown
v-if="showMoreDropdown"
@@ -123,6 +123,8 @@ const XuguServerDashboard = defineAsyncComponent(() => import("@/components/admi
const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue"));
const DamengUserAdmin = defineAsyncComponent(() => import("@/components/admin/DamengUserAdmin.vue"));
const DamengRoleAdmin = defineAsyncComponent(() => import("@/components/admin/DamengRoleAdmin.vue"));
const PluginWorkbenchTab = defineAsyncComponent(() => import("@/components/plugins/PluginWorkbenchTab.vue"));
const PluginFilesystemTab = defineAsyncComponent(() => import("@/components/plugins/PluginFilesystemTab.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
@@ -981,6 +983,25 @@ function onRefreshActiveKvBrowser(event: Event) {
void nextTick(() => refreshData());
}
function openPluginResultView(pluginId: string, contributionId: string, label: string) {
const result = props.activeTab.result;
if (!result) return;
// Plugin workbenches receive a bounded snapshot; plugins re-query through
// their backend when they need the full or streamed result set.
const cappedRows = result.rows.slice(0, 500);
queryStore.openPluginWorkbench(pluginId, contributionId, {
title: label,
connectionId: props.activeTab.connectionId || "",
database: props.activeTab.database || "",
context: {
connectionId: props.activeTab.connectionId || "",
database: props.activeTab.database || "",
sql: props.activeTab.sql,
result: { columns: result.columns, rows: cappedRows, truncated: result.rows.length > cappedRows.length },
},
});
}
async function exportResultArchive() {
if (resultArchiveExporting.value) return;
resultArchiveExporting.value = true;
@@ -1712,9 +1733,11 @@ defineExpose({
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="standaloneResultToolbarCompact"
:has-result="!!activeTab.result"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
@export-archive="exportResultArchive"
@open-result-view="openPluginResultView"
/>
</div>
@@ -1953,9 +1976,11 @@ defineExpose({
:can-export-archive="canExportResultArchive"
:archive-exporting="resultArchiveExporting"
:compact="compact"
:has-result="!!activeTab.result"
@select-explain="emit('update:activeOutputView', activeTab.id, 'explain')"
@select-profile="emit('update:activeOutputView', activeTab.id, 'profile')"
@export-archive="exportResultArchive"
@open-result-view="openPluginResultView"
/>
</template>
<template v-if="activeTab.result && isQueryExecutionErrorResult(activeTab.result)" #error-actions="{ errorMessage }">
@@ -2501,6 +2526,23 @@ defineExpose({
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-workbench' && activeTab.pluginWorkbench">
<div class="flex-1 min-h-0">
<PluginWorkbenchTab :key="activeTab.id" :plugin-id="activeTab.pluginWorkbench.pluginId" :contribution-id="activeTab.pluginWorkbench.contributionId" :context="activeTab.pluginWorkbench.context" />
</div>
</template>
<template v-else-if="activeTab.mode === 'plugin-filesystem' && activeTab.pluginFilesystem">
<div class="flex h-full min-h-0 flex-col">
<PluginFilesystemTab
:key="activeTab.id"
:plugin-id="activeTab.pluginFilesystem.pluginId"
:provider-id="activeTab.pluginFilesystem.providerId"
:connection-id="activeTab.connectionId || undefined"
:root-uri="activeTab.pluginFilesystem.rootUri"
:initial-uri="activeTab.pluginFilesystem.currentUri"
/>
</div>
</template>
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
<div class="min-w-0 flex-1 min-h-0">
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
@@ -1,12 +1,16 @@
<script setup lang="ts">
import { GitBranch, Gauge, Loader2, Upload } from "@lucide/vue";
import { computed, ref } from "vue";
import { GitBranch, Gauge, Loader2, PlugZap, Upload } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry, type PluginContributionEntry } from "@/lib/plugins/frontendPlugin";
import type { InstalledPlugin, PluginResultViewContribution } from "@/types/database";
type OutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
withDefaults(
const props = withDefaults(
defineProps<{
activeView: OutputView;
canShowExplain: boolean;
@@ -14,17 +18,30 @@ withDefaults(
canExportArchive: boolean;
archiveExporting: boolean;
compact?: boolean;
hasResult?: boolean;
}>(),
{ compact: false },
{ compact: false, hasResult: false },
);
const emit = defineEmits<{
selectExplain: [];
selectProfile: [];
exportArchive: [];
openResultView: [pluginId: string, contributionId: string, label: string];
}>();
const { t } = useI18n();
const { t, locale: appLocale } = useI18n();
const installedPlugins = ref<InstalledPlugin[]>([]);
const resultViews = computed<PluginContributionEntry<PluginResultViewContribution>[]>(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value).listResultViews());
const visibleResultViews = computed(() => (props.hasResult ? resultViews.value.slice(0, 4) : []));
void api.listPlugins().then(
(plugins) => {
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
},
() => {},
);
</script>
<template>
@@ -79,5 +96,19 @@ const { t } = useI18n();
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ t("tabs.exportResultArchive") }}</span>
</Button>
</LightTooltip>
<LightTooltip v-for="view in visibleResultViews" :key="view.plugin.manifest.id + ':' + view.contribution.id" :text="t('pluginPlatform.openResultView', { label: view.contribution.label })" :disabled="!compact" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button
variant="ghost"
size="sm"
class="h-5 shrink-0 text-xs leading-none text-muted-foreground hover:text-foreground"
:class="compact ? 'w-6 gap-0 px-0' : 'gap-1 px-2'"
:title="t('pluginPlatform.openResultView', { label: view.contribution.label })"
:aria-label="t('pluginPlatform.openResultView', { label: view.contribution.label })"
@click="emit('openResultView', view.plugin.manifest.id, view.contribution.id, view.contribution.label)"
>
<PlugZap class="block h-3.5 w-3.5 self-center" />
<span v-if="!compact" class="inline-flex h-4 items-center leading-none">{{ view.contribution.label }}</span>
</Button>
</LightTooltip>
</div>
</template>
@@ -19,7 +19,7 @@ describe("AppTabBar shared group navigation", () => {
});
it("retains targets while hiding inactive special content and follows all placements", () => {
expect(tabBarSource).toContain('v-show="driverStoreActive || settingsPageActive"');
expect(tabBarSource).toContain('v-show="driverStoreActive || pluginCenterActive || settingsPageActive"');
expect(tabBarSource).toContain("data-special-page-navigation");
expect(tabBarSource).toContain("data-special-page-content");
expect(tabBarSource).toContain("<slot />");
@@ -10,7 +10,7 @@ const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "ut
// assertions pin the sibling order so a future merge cannot re-introduce it.
describe("App main content surface structure", () => {
it("hides only the editor workspace via the surface guard, never the special pages", () => {
const guard = 'v-show="!driverStoreActive && !settingsStore.settingsPageActive"';
const guard = 'v-show="!driverStoreActive && !pluginCenterActive && !settingsStore.settingsPageActive"';
// The empty-state slot shares the query surface's visibility guard.
expect(appSource.split(guard).length - 1).toBe(1);
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, reactive, type App } from "vue";
import { afterEach, describe, expect, it } from "vitest";
import i18n from "@/i18n";
import PluginConnectionFields from "./PluginConnectionFields.vue";
import type { PluginConnectionProviderContribution, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
const mountedApps: App[] = [];
const contribution: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "example.connection",
label: "Example connection",
database_type: "example",
fields: [
{ key: "host", label: "Host", type: "text", required: true, placeholder: "localhost" },
{ key: "password", label: "Password", type: "password" },
{
key: "protocol",
label: "Protocol",
type: "radio",
default: "https",
options: [
{ label: "HTTPS", value: "https" },
{ label: "HTTP", value: "http" },
],
},
],
};
async function mountFields(initialValues: Record<string, PluginFormFieldValue> = {}, hiddenBindings: PluginFormFieldBinding[] = [], layout: "stacked" | "connection-dialog" = "stacked") {
const state = reactive({ values: initialValues });
const container = document.createElement("div");
document.body.append(container);
const app = createApp(
defineComponent({
setup() {
return () =>
h(PluginConnectionFields, {
contribution,
modelValue: state.values,
hiddenBindings,
layout,
"onUpdate:modelValue": (value: Record<string, PluginFormFieldValue>) => {
state.values = value;
},
});
},
}),
);
mountedApps.push(app);
app.use(i18n);
app.mount(container);
await nextTick();
return state;
}
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
});
describe("PluginConnectionFields", () => {
it("renders declared fields and emits immutable model updates", async () => {
const state = await mountFields({ password: "secret" });
const hostInput = document.querySelector<HTMLInputElement>("#example-connection-host");
const passwordInput = document.querySelector<HTMLInputElement>("#example-connection-password");
expect(hostInput?.placeholder).toBe("localhost");
expect(passwordInput?.value).toBe("secret");
if (!hostInput) throw new Error("host input not mounted");
hostInput.value = "db.internal";
hostInput.dispatchEvent(new Event("input", { bubbles: true }));
await nextTick();
expect(state.values).toEqual({ password: "secret", host: "db.internal" });
});
it("can hide host-owned common bindings", async () => {
contribution.fields[0].binding = "host";
await mountFields({}, ["host"]);
expect(document.querySelector("#example-connection-host")).toBeNull();
expect(document.querySelector("#example-connection-password")).not.toBeNull();
contribution.fields[0].binding = undefined;
});
it("uses the native four-column connection layout without a nested card", async () => {
await mountFields({}, [], "connection-dialog");
const root = document.querySelector(".contents");
const hostInput = document.querySelector("#example-connection-host");
const fieldRow = hostInput?.closest(".grid");
expect(root).not.toBeNull();
expect(root?.textContent).not.toContain("Example connection");
expect(fieldRow?.classList.contains("grid-cols-4")).toBe(true);
expect(document.querySelector(".rounded-lg.border")).toBeNull();
});
it("renders radio fields and emits the selected option", async () => {
const state = await mountFields();
const radios = Array.from(document.querySelectorAll<HTMLInputElement>("input[type='radio']"));
expect(radios).toHaveLength(2);
expect(radios[0]?.checked).toBe(true);
radios[1]?.click();
await nextTick();
expect(state.values.protocol).toBe("http");
});
});
@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from "vue";
import { Input } from "@/components/ui/input";
import PasswordInput from "@/components/ui/PasswordInput.vue";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import type { PluginConnectionProviderContribution, PluginFormField, PluginFormFieldBinding, PluginFormFieldValue } from "@/types/database";
const props = defineProps<{
contribution: PluginConnectionProviderContribution;
modelValue: Record<string, PluginFormFieldValue>;
hiddenBindings?: PluginFormFieldBinding[];
layout?: "stacked" | "connection-dialog";
}>();
const emit = defineEmits<{
"update:modelValue": [value: Record<string, PluginFormFieldValue>];
}>();
const formValues = computed(() => props.modelValue);
const visibleFields = computed(() => props.contribution.fields.filter((field) => !field.binding || !props.hiddenBindings?.includes(field.binding)));
const isConnectionDialogLayout = computed(() => props.layout === "connection-dialog");
function fieldValue(field: PluginFormField): PluginFormFieldValue {
return formValues.value[field.key] ?? field.default ?? defaultValueFor(field);
}
function updateField(field: PluginFormField, value: PluginFormFieldValue) {
emit("update:modelValue", { ...formValues.value, [field.key]: value });
}
function updateTextField(field: PluginFormField, value: string | number) {
if (field.type === "number") {
updateField(field, value === "" ? undefined : Number(value));
return;
}
updateField(field, String(value));
}
function updateBooleanField(field: PluginFormField, value: boolean) {
updateField(field, value);
}
function updateSelectField(field: PluginFormField, value: unknown) {
if (value === null || value === undefined) {
updateField(field, undefined);
return;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") updateField(field, value);
}
function fieldId(field: PluginFormField): string {
return `${props.contribution.id}-${field.key}`.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function defaultValueFor(field: PluginFormField): PluginFormFieldValue {
if (field.type === "boolean") return false;
if (field.type === "number") return undefined;
return "";
}
</script>
<template>
<div :class="isConnectionDialogLayout ? 'contents' : 'space-y-4'">
<div v-if="!isConnectionDialogLayout && (contribution.label || contribution.description)" class="space-y-1">
<div v-if="contribution.label" class="text-sm font-medium">{{ contribution.label }}</div>
<div v-if="contribution.description" class="text-xs text-muted-foreground">{{ contribution.description }}</div>
</div>
<div v-else-if="isConnectionDialogLayout && contribution.description" class="col-span-full grid grid-cols-4 items-start gap-4">
<span />
<p class="col-span-3 m-0 text-xs leading-5 text-muted-foreground">{{ contribution.description }}</p>
</div>
<div v-for="field in visibleFields" :key="field.key" :class="isConnectionDialogLayout ? 'col-span-full grid grid-cols-4 items-start gap-4' : 'space-y-1.5'">
<Label :for="fieldId(field)" :class="isConnectionDialogLayout ? 'justify-self-start pt-2 text-left' : 'text-xs'">
{{ field.label }}
<span v-if="field.required" class="text-destructive">*</span>
</Label>
<div :class="isConnectionDialogLayout ? 'col-span-3 min-w-0 space-y-1.5' : ''">
<Input v-if="field.type === 'text' || field.type === 'number'" :id="fieldId(field)" :type="field.type === 'number' ? 'number' : 'text'" :model-value="fieldValue(field) as string | number | undefined" :placeholder="field.placeholder" @update:model-value="updateTextField(field, $event)" />
<PasswordInput v-else-if="field.type === 'password'" :id="fieldId(field)" :model-value="String(fieldValue(field) ?? '')" :placeholder="field.placeholder" @update:model-value="updateField(field, $event)" />
<textarea
v-else-if="field.type === 'textarea'"
:id="fieldId(field)"
:value="String(fieldValue(field) ?? '')"
:placeholder="field.placeholder"
class="min-h-20 w-full resize-y rounded-md border border-input bg-transparent px-2.5 py-2 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
@input="updateField(field, ($event.target as HTMLTextAreaElement).value)"
/>
<Select v-else-if="field.type === 'select'" :model-value="String(fieldValue(field) ?? '')" @update:model-value="updateSelectField(field, $event)">
<SelectTrigger :id="fieldId(field)" :class="isConnectionDialogLayout ? 'h-9' : 'h-8 text-xs'">
<SelectValue :placeholder="field.placeholder" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="option in field.options || []" :key="option.value" :value="option.value">
{{ option.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-else-if="field.type === 'radio'" :id="fieldId(field)" class="flex min-h-9 flex-wrap items-center gap-x-4 gap-y-2" role="radiogroup" :aria-label="field.label">
<label v-for="option in field.options || []" :key="option.value" class="inline-flex cursor-pointer items-center gap-2 text-sm">
<input type="radio" :name="fieldId(field)" :value="option.value" :checked="String(fieldValue(field) ?? '') === option.value" class="size-4 accent-primary" @change="updateField(field, option.value)" />
<span>{{ option.label }}</span>
</label>
</div>
<div v-else-if="field.type === 'boolean'" class="flex h-9 items-center">
<Switch :id="fieldId(field)" :model-value="Boolean(fieldValue(field))" size="sm" @update:model-value="updateBooleanField(field, $event)" />
</div>
<div v-if="field.description" class="text-[11px] leading-5 text-muted-foreground">{{ field.description }}</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,871 @@
<script setup lang="ts">
import { computed, h, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { Check, ChevronRight, CircleAlert, Download, ExternalLink, FileUp, FolderTree, Globe, LayoutGrid, List, Loader2, PackageCheck, Pencil, Plus, RefreshCw, RotateCcw, Search, Settings2, ShieldCheck, Store, Trash2 } from "@lucide/vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/composables/useToast";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import * as api from "@/lib/backend/api";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { physicalDropPositionInsideRect } from "@/lib/ai/aiAttachments";
import { createFrontendPluginRegistry, pluginConnectionProviderIcon } from "@/lib/plugins/frontendPlugin";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, type MarketplacePluginListing } from "@/lib/plugins/pluginMarketplace";
import type { PluginCenterFocus } from "@/lib/plugins/pluginCenterNavigation";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import type { InstalledPlugin, PluginRepository, PluginRepositoryCatalogResult, PluginTrustedKey } from "@/types/database";
import { useI18n } from "vue-i18n";
const props = defineProps<{
focusTarget?: PluginCenterFocus | null;
}>();
const emit = defineEmits<{
newConnection: [pluginId: string, providerId: string];
}>();
// lucide no longer ships brand icons; mirror the inline glyph used in AppToolbar.
const GithubIcon = {
render() {
return h("svg", { class: "size-3", viewBox: "0 0 24 24", fill: "currentColor" }, [
h("path", {
d: "M12 0C5.37 0 0 5.37 0 12c0 5.3 3.438 9.8 8.205 11.387.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61-.546-1.387-1.333-1.756-1.333-1.756-1.09-.745.083-.729.083-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 21.795 24 17.295 24 12 24 5.37 18.627 0 12 0z",
}),
]);
},
};
const PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY = "dbx-plugin-allow-unsigned";
type TauriFileDropPayload = { type: "enter"; paths: string[]; position: { x: number; y: number } } | { type: "over"; position: { x: number; y: number } } | { type: "drop"; paths: string[]; position: { x: number; y: number } } | { type: "leave" };
const { t, locale: appLocale } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const activeSection = ref<"marketplace" | "installed" | "settings">("marketplace");
const installedPlugins = ref<InstalledPlugin[]>([]);
const trustedKeys = ref<PluginTrustedKey[]>([]);
const repositories = ref<PluginRepository[]>([]);
const catalogResults = ref<PluginRepositoryCatalogResult[]>([]);
const loading = ref(false);
const marketplaceLoading = ref(false);
const installing = ref(false);
const marketplaceInstallingKey = ref("");
const operating = ref(false);
const error = ref("");
const selectedPluginId = ref("");
const selectedContributionId = ref("");
const selectedConnectionId = ref("");
const allowUnsigned = ref(
((): boolean => {
try {
return localStorage.getItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY) === "1";
} catch {
return false;
}
})(),
);
const trustedKeyId = ref("");
const trustedPublicKey = ref("");
const repositoryId = ref("");
const repositoryName = ref("");
const repositoryCatalogUrl = ref("");
const marketplaceQuery = ref("");
const marketplaceRepositoryId = ref("all");
const marketplaceViewMode = ref<"grid" | "list">("grid");
const webFileInput = ref<HTMLInputElement | null>(null);
const panelRootRef = ref<HTMLElement | null>(null);
const draggingPackage = ref(false);
let webDragDepth = 0;
const registry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
const definitions = computed(() => registry.value.listPlugins());
const connectionProviders = computed(() => registry.value.listConnectionProviders());
const selectedEntry = computed(() => connectionProviders.value.find((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value) || null);
const selectedDefinition = computed(() => definitions.value.find((definition) => definition.plugin.manifest.id === selectedPluginId.value) || null);
const selectedWorkbenches = computed(() => registry.value.listWorkbenches().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
const selectedFilesystems = computed(() => registry.value.listFilesystemProviders().filter((entry) => entry.plugin.manifest.id === selectedPluginId.value));
const providerConnections = computed(() => {
const entry = selectedEntry.value;
if (!entry) return [];
return connectionStore.connections.filter((connection) => connection.db_type === "plugin" && connection.plugin_id === entry.plugin.manifest.id && connection.plugin_connection_provider === entry.contribution.id);
});
const selectedConnection = computed(() => providerConnections.value.find((connection) => connection.id === selectedConnectionId.value));
const marketplaceListings = computed(() => buildMarketplacePluginListings(catalogResults.value, installedPlugins.value, appLocale.value));
const filteredMarketplaceListings = computed(() => filterMarketplacePluginListings(marketplaceListings.value, marketplaceQuery.value, marketplaceRepositoryId.value));
const catalogErrors = computed(() => catalogResults.value.filter((result) => result.error));
const customRepositories = computed(() => repositories.value.filter((repository) => !repository.managed));
const showCustomRepositoryTrustSettings = computed(() => customRepositories.value.length > 0 || trustedKeys.value.length > 0);
function openExternal(url?: string) {
const target = url?.trim();
if (!target) return;
try {
const parsed = new URL(target);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
} catch {
return;
}
if (isTauriRuntime()) void import("@tauri-apps/plugin-shell").then(({ open }) => open(target));
else window.open(target, "_blank", "noopener,noreferrer");
}
async function refresh(preferredPluginId = props.focusTarget?.pluginId || selectedPluginId.value) {
loading.value = true;
error.value = "";
try {
[installedPlugins.value, trustedKeys.value, repositories.value] = await Promise.all([api.listPlugins(), api.listPluginTrustedKeys(), api.listPluginRepositories()]);
await refreshMarketplace();
if (props.focusTarget) applyFocusTarget(props.focusTarget);
else selectFirstProvider(preferredPluginId);
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
loading.value = false;
}
}
async function refreshMarketplace() {
marketplaceLoading.value = true;
try {
catalogResults.value = await api.fetchPluginMarketplaceCatalogs();
} catch (cause) {
catalogResults.value = [];
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
marketplaceLoading.value = false;
}
}
function applyFocusTarget(focus: PluginCenterFocus) {
activeSection.value = "installed";
if (!focus.pluginId) return selectFirstProvider();
const provider = connectionProviders.value.find((entry) => entry.plugin.manifest.id === focus.pluginId && (!focus.providerId || entry.contribution.id === focus.providerId));
if (!provider) {
selectPlugin(focus.pluginId);
return;
}
selectProvider(focus.pluginId, provider.contribution.id);
}
async function installMarketplaceListing(listing: MarketplacePluginListing) {
if (!listing.artifact || listing.status === "installed") return;
marketplaceInstallingKey.value = listing.key;
try {
const result = await api.installMarketplacePlugin({
repositoryId: listing.repository.id,
pluginId: listing.plugin.id,
version: listing.plugin.latestVersion,
});
toast(t(listing.status === "update" ? "pluginPlatform.updateSuccess" : "pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
} finally {
marketplaceInstallingKey.value = "";
}
}
async function saveRepository() {
const id = repositoryId.value.trim();
const name = repositoryName.value.trim();
const catalogUrl = repositoryCatalogUrl.value.trim();
if (!id || !name || !catalogUrl) return toast(t("pluginPlatform.repositoryFieldsRequired"));
operating.value = true;
try {
repositories.value = await api.savePluginRepository({ id, name, catalogUrl, kind: "custom", enabled: true, managed: false });
repositoryId.value = "";
repositoryName.value = "";
repositoryCatalogUrl.value = "";
toast(t("pluginPlatform.repositorySaved", { name }));
await refreshMarketplace();
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
async function toggleRepository(repository: PluginRepository) {
if (repository.managed) return;
operating.value = true;
try {
repositories.value = await api.savePluginRepository({ ...repository, enabled: !repository.enabled });
await refreshMarketplace();
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
async function removeRepository(repository: PluginRepository) {
if (repository.managed || !window.confirm(t("pluginPlatform.removeRepositoryConfirm", { name: repository.name }))) return;
operating.value = true;
try {
repositories.value = await api.removePluginRepository(repository.id);
if (marketplaceRepositoryId.value === repository.id) marketplaceRepositoryId.value = "all";
toast(t("pluginPlatform.repositoryRemoved", { name: repository.name }));
await refreshMarketplace();
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
async function saveTrustedKey() {
const keyId = trustedKeyId.value.trim();
const publicKey = trustedPublicKey.value.trim();
if (!keyId || !publicKey) return;
operating.value = true;
try {
trustedKeys.value = await api.savePluginTrustedKey(keyId, publicKey);
trustedKeyId.value = "";
trustedPublicKey.value = "";
toast(t("pluginPlatform.repositoryKeyAdded", { keyId }));
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
async function removeTrustedKey(keyId: string) {
if (!window.confirm(t("pluginPlatform.removeRepositoryKeyConfirm", { keyId }))) return;
operating.value = true;
try {
trustedKeys.value = await api.removePluginTrustedKey(keyId);
toast(t("pluginPlatform.repositoryKeyRemoved", { keyId }));
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
function abbreviatedPublicKey(publicKey: string): string {
return publicKey.length <= 24 ? publicKey : `${publicKey.slice(0, 12)}…${publicKey.slice(-8)}`;
}
function selectFirstProvider(preferredPluginId = "") {
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === preferredPluginId) || connectionProviders.value[0];
if (first) return selectProvider(first.plugin.manifest.id, first.contribution.id);
selectedPluginId.value = definitions.value.find((definition) => definition.plugin.manifest.id === preferredPluginId)?.plugin.manifest.id || definitions.value[0]?.plugin.manifest.id || "";
selectedContributionId.value = "";
selectedConnectionId.value = "";
}
function selectProvider(pluginId: string, contributionId: string) {
selectedPluginId.value = pluginId;
selectedContributionId.value = contributionId;
const existing = connectionStore.connections.find((connection) => connection.db_type === "plugin" && connection.plugin_id === pluginId && connection.plugin_connection_provider === contributionId);
selectedConnectionId.value = existing?.id || "";
}
function selectPlugin(pluginId: string) {
const first = connectionProviders.value.find((entry) => entry.plugin.manifest.id === pluginId);
if (first) return selectProvider(pluginId, first.contribution.id);
selectedPluginId.value = pluginId;
selectedContributionId.value = "";
selectedConnectionId.value = "";
}
function selectConnection(connectionId: string) {
selectedConnectionId.value = connectionId;
}
function editConnection(connectionId: string) {
connectionStore.startEditing(connectionId);
}
function createConnection() {
const entry = selectedEntry.value;
if (!entry) return toast(t("pluginPlatform.selectConnectionProvider"));
emit("newConnection", entry.plugin.manifest.id, entry.contribution.id);
}
async function openFilesystem(pluginId: string, providerId: string, label: string, rootUri?: string) {
const connection = selectedConnection.value;
try {
if (connection) await connectionStore.ensureConnected(connection.id);
queryStore.openPluginFilesystem(pluginId, providerId, {
title: connection ? `${connection.name} · ${label}` : label,
connectionId: connection?.id,
rootUri,
});
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
}
}
function openWorkbench(pluginId: string, contributionId: string, label: string) {
const connection = selectedConnection.value;
queryStore.openPluginWorkbench(pluginId, contributionId, {
title: connection ? `${connection.name} · ${label}` : label,
connectionId: connection?.id,
context: connection
? {
connectionId: connection.id,
providerId: connection.plugin_connection_provider,
connectionType: connection.plugin_connection_type,
}
: undefined,
});
}
async function choosePluginPackage() {
if (!isTauriRuntime()) {
webFileInput.value?.click();
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const path = await open({ multiple: false, filters: [{ name: t("pluginPlatform.packageFileType"), extensions: ["dbxp"] }] });
if (typeof path === "string") await installPlugin(path);
}
async function handleWebPackage(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (file) await installPlugin(file);
}
async function installPlugin(source: string | File) {
installing.value = true;
try {
const result = await api.installPluginPackage(source, allowUnsigned.value);
toast(t("pluginPlatform.installSuccess", { name: result.plugin.manifest.name, version: result.plugin.manifest.version }));
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
activeSection.value = "installed";
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 8000);
} finally {
installing.value = false;
}
}
function isPluginPackagePath(path: string): boolean {
return /\.dbxp$/i.test(path);
}
function webDropPluginPackage(event: DragEvent): File | null {
const files = event.dataTransfer?.files;
if (!files) return null;
for (let i = 0; i < files.length; i++) {
if (isPluginPackagePath(files[i].name)) return files[i];
}
return null;
}
function onWebDragEnter(event: DragEvent) {
if (!webDropPluginPackage(event)) return;
event.preventDefault();
webDragDepth++;
draggingPackage.value = true;
}
function onWebDragOver(event: DragEvent) {
if (!webDropPluginPackage(event)) return;
event.preventDefault();
}
function onWebDragLeave() {
if (webDragDepth > 0 && --webDragDepth === 0) draggingPackage.value = false;
}
function onWebDrop(event: DragEvent) {
const claimed = draggingPackage.value;
webDragDepth = 0;
draggingPackage.value = false;
const file = webDropPluginPackage(event);
if (!file) {
if (claimed) event.preventDefault();
return;
}
event.preventDefault();
event.stopPropagation();
if (installing.value) return;
void installPlugin(file);
}
function dropInsidePanel(payload: Exclude<TauriFileDropPayload, { type: "leave" }>): boolean {
const root = panelRootRef.value;
if (!root) return false;
return physicalDropPositionInsideRect(payload.position, root.getBoundingClientRect(), window.devicePixelRatio);
}
function onTauriPluginDrop(event: Event) {
const routedEvent = event as CustomEvent<TauriFileDropPayload>;
const payload = routedEvent.detail;
if (!payload) return;
if (payload.type === "leave") {
draggingPackage.value = false;
return;
}
const inside = dropInsidePanel(payload);
if (payload.type === "enter" || payload.type === "over") {
const relevant = payload.type === "enter" ? inside && payload.paths.some(isPluginPackagePath) : inside;
if (relevant) routedEvent.preventDefault();
draggingPackage.value = relevant;
return;
}
draggingPackage.value = false;
const path = payload.paths.find(isPluginPackagePath);
if (!inside || !path || installing.value) return;
routedEvent.preventDefault();
void installPlugin(path);
}
async function rollbackSelectedPlugin() {
if (!selectedPluginId.value || !window.confirm(t("pluginPlatform.rollbackConfirm"))) return;
operating.value = true;
try {
const result = await api.rollbackPlugin(selectedPluginId.value);
toast(t("pluginPlatform.rollbackSuccess", { version: result.plugin.manifest.version }));
installedPlugins.value = await api.listPlugins();
selectPlugin(result.plugin.manifest.id);
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
async function uninstallSelectedPlugin() {
const definition = selectedDefinition.value;
if (!definition || !window.confirm(t("pluginPlatform.uninstallConfirm", { name: definition.plugin.manifest.name }))) return;
operating.value = true;
try {
installedPlugins.value = await api.uninstallPlugin(definition.plugin.manifest.id);
toast(t("pluginPlatform.uninstallSuccess", { name: definition.plugin.manifest.name }));
selectFirstProvider();
} catch (cause) {
toast(cause instanceof Error ? cause.message : String(cause), 5000);
} finally {
operating.value = false;
}
}
watch(connectionProviders, (providers) => {
if (!providers.some((entry) => entry.plugin.manifest.id === selectedPluginId.value && entry.contribution.id === selectedContributionId.value)) selectFirstProvider(selectedPluginId.value);
});
watch(providerConnections, (connections) => {
if (selectedConnectionId.value && connections.some((connection) => connection.id === selectedConnectionId.value)) return;
selectedConnectionId.value = connections[0]?.id || "";
});
watch(
() => props.focusTarget,
(focus) => {
if (focus && installedPlugins.value.length) applyFocusTarget(focus);
},
{ deep: true },
);
watch(allowUnsigned, (value) => {
try {
localStorage.setItem(PLUGIN_ALLOW_UNSIGNED_STORAGE_KEY, value ? "1" : "0");
} catch {
// storage unavailable (private mode); the flag stays session-only
}
});
onMounted(() => {
void refresh();
if (isTauriRuntime()) document.addEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
});
onBeforeUnmount(() => {
if (isTauriRuntime()) document.removeEventListener("dbx:tauri-file-drop", onTauriPluginDrop);
});
</script>
<template>
<div ref="panelRootRef" class="plugin-center-view relative mx-auto flex h-full w-full max-w-6xl flex-col gap-4 overflow-hidden px-6 py-6" @dragenter="onWebDragEnter" @dragover="onWebDragOver" @dragleave="onWebDragLeave" @drop="onWebDrop">
<input ref="webFileInput" type="file" accept=".dbxp" class="hidden" @change="handleWebPackage" />
<div v-if="error" class="shrink-0 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">{{ error }}</div>
<Tabs v-model="activeSection" class="min-h-0 flex-1 gap-3">
<TabsList class="grid h-9 w-full grid-cols-3">
<TabsTrigger value="marketplace" class="gap-1.5 text-xs"><Store class="size-3.5" />{{ t("pluginPlatform.marketplace") }}</TabsTrigger>
<TabsTrigger value="installed" class="gap-1.5 text-xs"><PackageCheck class="size-3.5" />{{ t("pluginPlatform.installed") }}</TabsTrigger>
<TabsTrigger value="settings" class="gap-1.5 text-xs"><Settings2 class="size-3.5" />{{ t("pluginPlatform.settings") }}</TabsTrigger>
</TabsList>
<TabsContent value="marketplace" class="m-0 min-h-0 flex-1 overflow-y-auto">
<div class="flex min-h-full w-full flex-col gap-4 pb-2">
<div class="flex w-full flex-col gap-2 rounded-xl border bg-card/70 p-3 sm:flex-row sm:items-center">
<div class="relative">
<Search class="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input v-model="marketplaceQuery" class="h-8 min-w-0 pl-8 text-xs sm:w-[min(100%,28rem)]" :placeholder="t('pluginPlatform.searchMarketplace')" />
</div>
<div class="flex min-w-0 items-center gap-2 sm:ml-auto">
<Select v-model="marketplaceRepositoryId">
<SelectTrigger class="h-8 min-w-0 flex-1 text-xs sm:w-52 sm:flex-none"><SelectValue :placeholder="t('pluginPlatform.allRepositories')" /></SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ t("pluginPlatform.allRepositories") }}</SelectItem>
<SelectItem v-for="repository in repositories.filter((entry) => entry.enabled)" :key="repository.id" :value="repository.id">{{ repository.name }}</SelectItem>
</SelectContent>
</Select>
<div class="flex shrink-0 items-center rounded-md border bg-muted/20 p-0.5">
<button
type="button"
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
:class="marketplaceViewMode === 'grid' ? 'bg-background text-foreground shadow-sm' : ''"
:aria-label="t('structure.viewGrid')"
:aria-pressed="marketplaceViewMode === 'grid'"
@click="marketplaceViewMode = 'grid'"
>
<LayoutGrid class="size-3.5" />
</button>
<button
type="button"
class="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
:class="marketplaceViewMode === 'list' ? 'bg-background text-foreground shadow-sm' : ''"
:aria-label="t('structure.viewList')"
:aria-pressed="marketplaceViewMode === 'list'"
@click="marketplaceViewMode = 'list'"
>
<List class="size-3.5" />
</button>
</div>
<Button variant="ghost" size="icon-sm" class="shrink-0" :disabled="marketplaceLoading" :title="t('common.refresh')" :aria-label="t('common.refresh')" @click="refreshMarketplace"><RefreshCw class="size-3.5" :class="marketplaceLoading ? 'animate-spin' : ''" /></Button>
</div>
</div>
<div v-for="result in catalogErrors" :key="result.repository.id" class="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<CircleAlert class="mt-0.5 size-3.5 shrink-0" />
<div>
<span class="font-medium">{{ result.repository.name }}:</span> {{ result.error }}
</div>
</div>
<div v-if="marketplaceLoading" class="flex min-h-[440px] flex-1 items-center justify-center gap-2 rounded-lg border border-dashed p-12 text-xs text-muted-foreground"><Loader2 class="size-4 animate-spin" />{{ t("pluginPlatform.loadingMarketplace") }}</div>
<div v-else-if="!filteredMarketplaceListings.length" class="flex min-h-[440px] flex-1 flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<Store class="size-7 text-muted-foreground" />
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noMarketplacePlugins") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noMarketplacePluginsDescription") }}</div>
</div>
<div v-else-if="marketplaceViewMode === 'grid'" class="grid w-full grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-3">
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="group flex min-w-0 min-h-48 flex-col rounded-xl border bg-card p-4 transition-colors hover:border-primary/40">
<div class="flex items-start gap-3">
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-11 rounded-xl border bg-background p-1.5" />
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-1.5">
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
<Badge v-if="listing.verified" variant="secondary" class="h-5 gap-1 px-1.5 text-[10px]"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
</div>
<div class="mt-1 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
<GithubIcon />
</button>
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
<Globe class="size-3" />
</button>
</div>
</div>
<Badge variant="outline" class="h-5 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
</div>
<p class="mt-3 line-clamp-3 text-xs leading-5 text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
<div class="mt-3 flex flex-wrap gap-1.5">
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
<Badge v-if="listing.plugin.permissions.length" variant="outline" class="h-5 px-1.5 text-[10px]">{{ t("pluginPlatform.permissionsCount", { count: listing.plugin.permissions.length }) }}</Badge>
</div>
<div class="mt-auto flex items-end justify-between gap-3 pt-4">
<div class="text-[10px] text-muted-foreground">
<span v-if="listing.status === 'unsupported'">{{ t("pluginPlatform.unsupportedTarget", { target: listing.target }) }}</span>
<span v-else-if="listing.installed">{{ t("pluginPlatform.installedVersion", { version: listing.installed.manifest.version }) }}</span>
<span v-else>{{ listing.plugin.license || t("pluginPlatform.licenseUnknown") }}</span>
</div>
<button
type="button"
class="inline-flex h-7 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
@click="installMarketplaceListing(listing)"
>
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
<Download v-else class="size-3.5" />
{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}
</button>
</div>
</article>
</div>
<div v-else class="flex w-full flex-col gap-2">
<article v-for="listing in filteredMarketplaceListings" :key="listing.key" class="flex items-center gap-3 rounded-xl border bg-card p-3 transition-colors hover:border-primary/40">
<PluginIcon :plugin-id="listing.plugin.id" :icon="listing.plugin.icon" class="size-10 rounded-lg border bg-background p-1.5" />
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="truncate text-sm font-semibold">{{ listing.name }}</span>
<Badge v-if="listing.verified" variant="secondary" class="hidden h-5 shrink-0 gap-1 px-1.5 text-[10px] sm:inline-flex"><ShieldCheck class="size-3" />{{ t("pluginPlatform.verified") }}</Badge>
<Badge variant="outline" class="h-5 shrink-0 px-1.5 text-[10px]">v{{ listing.plugin.latestVersion }}</Badge>
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1 text-[11px] text-muted-foreground">
<span class="truncate">{{ listing.plugin.publisher }} · {{ listing.repository.name }}</span>
<button v-if="listing.plugin.source" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.sourceRepository')" :aria-label="t('pluginPlatform.sourceRepository')" @click.stop="openExternal(listing.plugin.source)">
<GithubIcon />
</button>
<button v-if="listing.plugin.homepage" type="button" class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100" :title="t('pluginPlatform.pluginHomepage')" :aria-label="t('pluginPlatform.pluginHomepage')" @click.stop="openExternal(listing.plugin.homepage)">
<Globe class="size-3" />
</button>
</div>
<p class="mt-1 truncate text-xs text-muted-foreground">{{ listing.description || t("pluginPlatform.noDescription") }}</p>
</div>
<div class="hidden max-w-52 shrink-0 gap-1.5 lg:flex">
<Badge v-for="tag in listing.plugin.tags.slice(0, 3)" :key="tag" variant="outline" class="h-5 px-1.5 text-[10px]">{{ tag }}</Badge>
</div>
<button
type="button"
class="inline-flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-full border-0 bg-gray-100 px-4 py-1 text-xs font-semibold transition-colors disabled:opacity-50 dark:bg-gray-800"
:class="listing.status === 'installed' || listing.status === 'unsupported' ? 'cursor-default text-gray-600 dark:text-gray-400' : 'text-blue-600 hover:bg-gray-200 dark:text-blue-400 dark:hover:bg-gray-700'"
:disabled="listing.status === 'installed' || listing.status === 'unsupported' || !!marketplaceInstallingKey"
@click="installMarketplaceListing(listing)"
>
<Loader2 v-if="marketplaceInstallingKey === listing.key" class="size-3.5 animate-spin" />
<Check v-else-if="listing.status === 'installed'" class="size-3.5" />
<CircleAlert v-else-if="listing.status === 'unsupported'" class="size-3.5" />
<RefreshCw v-else-if="listing.status === 'update'" class="size-3.5" />
<Download v-else class="size-3.5" />
<span class="hidden sm:inline">{{ t(`pluginPlatform.marketplaceStatus.${listing.status}`) }}</span>
</button>
</article>
</div>
</div>
</TabsContent>
<TabsContent value="installed" class="m-0 min-h-0 flex-1 overflow-y-auto">
<div v-if="loading && !installedPlugins.length" class="py-10 text-center text-xs text-muted-foreground">{{ t("common.loading") }}</div>
<div v-else-if="!installedPlugins.length" class="rounded-lg border border-dashed p-10 text-center">
<PackageCheck class="mx-auto size-7 text-muted-foreground" />
<div class="mt-3 text-sm font-medium">{{ t("pluginPlatform.noInstalledPlugins") }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ t("pluginPlatform.noInstalledPluginsDescription") }}</div>
<Button class="mt-4 gap-1.5" size="sm" @click="activeSection = 'marketplace'"><Store class="size-3.5" />{{ t("pluginPlatform.browseMarketplace") }}</Button>
</div>
<div v-else class="grid min-h-[440px] gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="space-y-1 rounded-lg border bg-muted/10 p-2">
<button
v-for="definition in definitions"
:key="definition.plugin.manifest.id"
type="button"
class="flex w-full items-start gap-2 rounded-md px-2 py-2 text-left hover:bg-muted"
:class="selectedPluginId === definition.plugin.manifest.id ? 'bg-muted ring-1 ring-primary/30' : ''"
@click="selectPlugin(definition.plugin.manifest.id)"
>
<Check v-if="selectedPluginId === definition.plugin.manifest.id" class="mt-0.5 size-3.5 shrink-0 text-primary" /><span v-else class="mt-0.5 size-3.5 shrink-0" />
<PluginIcon :plugin-id="definition.plugin.manifest.id" :icon="definition.plugin.manifest.icon" class="size-8 rounded-md border bg-background p-1" />
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ definition.plugin.manifest.name }}</span>
<span class="mt-1 flex flex-wrap gap-1">
<Badge variant="outline" class="h-4 px-1.5 text-[10px]">v{{ definition.plugin.manifest.version || "-" }}</Badge>
<Badge :variant="definition.plugin.compatibility.compatible ? 'secondary' : 'destructive'" class="h-4 px-1.5 text-[10px]">{{ definition.plugin.compatibility.compatible ? t("pluginPlatform.compatible") : t("pluginPlatform.blocked") }}</Badge>
</span>
</span>
</button>
</div>
<div class="space-y-4 rounded-lg border p-4">
<template v-if="selectedDefinition">
<div class="flex flex-wrap items-start justify-between gap-3 border-b pb-4">
<div class="flex min-w-0 items-start gap-3">
<PluginIcon :plugin-id="selectedDefinition.plugin.manifest.id" :icon="selectedDefinition.plugin.manifest.icon" class="size-10 rounded-lg border bg-background p-1.5" />
<div class="min-w-0">
<div class="text-sm font-medium">{{ selectedDefinition.plugin.manifest.name }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ selectedDefinition.plugin.manifest.description }}</div>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<span class="font-mono text-[10px] text-muted-foreground">{{ selectedDefinition.plugin.manifest.id }}</span>
<button
v-if="selectedDefinition.plugin.manifest.source"
type="button"
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
:title="t('pluginPlatform.sourceRepository')"
:aria-label="t('pluginPlatform.sourceRepository')"
@click="openExternal(selectedDefinition.plugin.manifest.source)"
>
<GithubIcon />
</button>
<button
v-if="selectedDefinition.plugin.manifest.homepage"
type="button"
class="rounded p-0.5 text-muted-foreground opacity-70 transition-opacity hover:text-foreground hover:opacity-100"
:title="t('pluginPlatform.pluginHomepage')"
:aria-label="t('pluginPlatform.pluginHomepage')"
@click="openExternal(selectedDefinition.plugin.manifest.homepage)"
>
<Globe class="size-3" />
</button>
</div>
</div>
</div>
<div class="flex gap-2">
<Button size="sm" variant="outline" class="gap-1.5" :disabled="operating" @click="rollbackSelectedPlugin"><RotateCcw class="size-3.5" />{{ t("pluginPlatform.rollback") }}</Button>
<Button size="sm" variant="outline" class="gap-1.5 text-destructive" :disabled="operating" @click="uninstallSelectedPlugin"><Trash2 class="size-3.5" />{{ t("pluginPlatform.uninstall") }}</Button>
</div>
</div>
<div v-if="connectionProviders.some((entry) => entry.plugin.manifest.id === selectedPluginId)" class="space-y-3">
<div class="flex flex-wrap gap-2">
<Button
v-for="entry in connectionProviders.filter((candidate) => candidate.plugin.manifest.id === selectedPluginId)"
:key="entry.contribution.id"
size="sm"
:variant="selectedContributionId === entry.contribution.id ? 'secondary' : 'outline'"
@click="selectProvider(entry.plugin.manifest.id, entry.contribution.id)"
><PluginIcon :plugin-id="entry.plugin.manifest.id" :icon="pluginConnectionProviderIcon(entry)" class="mr-1 size-3.5" />{{ entry.contribution.label }}</Button
>
</div>
<div v-if="selectedEntry" class="space-y-4 rounded-lg border p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="text-sm font-medium">{{ selectedEntry.contribution.label }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ selectedEntry.contribution.description || selectedEntry.contribution.database_type }}</div>
</div>
<Badge variant="outline">{{ t("pluginPlatform.connectionProvider") }}</Badge>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" class="gap-1.5" @click="createConnection"><Plus class="size-3.5" />{{ t("pluginPlatform.newConnection") }}</Button>
<div v-for="connection in providerConnections" :key="connection.id" class="inline-flex items-center">
<Button size="sm" class="rounded-r-none" :variant="selectedConnectionId === connection.id ? 'secondary' : 'outline'" @click="selectConnection(connection.id)">{{ connection.name }}</Button>
<Button size="icon" variant="outline" class="h-8 w-8 rounded-l-none border-l-0" :title="t('common.edit')" :aria-label="t('common.edit')" @click="editConnection(connection.id)"><Pencil class="size-3.5" /></Button>
</div>
</div>
<div class="rounded-md border border-dashed bg-muted/20 p-3 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.connectionManagedInDialog") }}</div>
</div>
</div>
<div v-if="selectedWorkbenches.length" class="space-y-2">
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.workbenches") }}</div>
<div v-for="entry in selectedWorkbenches" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
<div class="text-xs text-muted-foreground">{{ entry.contribution.description || entry.contribution.id }}</div>
</div>
<Button size="sm" variant="outline" class="gap-1.5" @click="openWorkbench(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label)"><ExternalLink class="size-3.5" />{{ t("pluginPlatform.open") }}</Button>
</div>
</div>
<div v-if="selectedFilesystems.length" class="space-y-2">
<div class="text-xs font-medium uppercase tracking-wide text-muted-foreground">{{ t("pluginPlatform.filesystemProviders") }}</div>
<div v-for="entry in selectedFilesystems" :key="entry.contribution.id" class="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<div class="text-sm font-medium">{{ entry.contribution.label }}</div>
<div class="mt-1 text-xs text-muted-foreground">{{ entry.contribution.schemes.join(", ") }} · {{ (entry.contribution.capabilities || []).join(", ") }}</div>
</div>
<Button size="sm" variant="outline" class="gap-1.5" @click="openFilesystem(entry.plugin.manifest.id, entry.contribution.id, entry.contribution.label, entry.contribution.root_uri)"><FolderTree class="size-3.5" />{{ t("pluginPlatform.browse") }}</Button>
</div>
</div>
</template>
</div>
</div>
</TabsContent>
<TabsContent value="settings" class="m-0 min-h-0 flex-1 overflow-y-auto">
<div class="space-y-4 pb-2">
<section class="space-y-3 rounded-xl border p-4">
<div class="flex items-start gap-3">
<div class="rounded-md bg-primary/10 p-2 text-primary"><FileUp class="size-4" /></div>
<div>
<div class="text-sm font-medium">{{ t("pluginPlatform.localInstallTitle") }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.localInstallDescription") }}</div>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" class="h-8 gap-1.5" :disabled="installing" @click="choosePluginPackage"><Loader2 v-if="installing" class="size-3.5 animate-spin" /><FileUp v-else class="size-3.5" />{{ t("pluginPlatform.installPackage") }}</Button>
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><ShieldCheck class="size-3.5 text-emerald-600 dark:text-emerald-400" />{{ t("pluginPlatform.signedPackagesVerifiedAutomatically") }}</div>
<div class="flex items-center gap-1.5 text-[11px] text-muted-foreground"><FileUp class="size-3.5" />{{ t("pluginPlatform.dropInstallHint") }}</div>
</div>
</section>
<section class="space-y-3 rounded-xl border p-4">
<div class="flex items-start gap-3">
<div class="rounded-md bg-primary/10 p-2 text-primary"><Store class="size-4" /></div>
<div>
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoriesTitle") }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoriesDescription") }}</div>
</div>
</div>
<div class="divide-y rounded-md border">
<div v-for="repository in repositories" :key="repository.id" class="flex flex-wrap items-center gap-3 px-3 py-2.5">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 text-xs font-medium">
<span>{{ repository.name }}</span
><Badge variant="outline" class="h-4 px-1.5 text-[9px]">{{ t(`pluginPlatform.repositoryKind.${repository.kind}`) }}</Badge>
</div>
<div class="mt-1 truncate font-mono text-[10px] text-muted-foreground">{{ repository.catalogUrl || t("pluginPlatform.repositoryNotConfigured") }}</div>
</div>
<Badge :variant="repository.enabled ? 'secondary' : 'outline'" class="h-5 px-1.5 text-[10px]">{{ repository.enabled ? t("pluginPlatform.enabled") : t("pluginPlatform.disabled") }}</Badge>
<Button v-if="!repository.managed" size="sm" variant="ghost" class="h-7" :disabled="operating" @click="toggleRepository(repository)">{{ repository.enabled ? t("pluginPlatform.disable") : t("pluginPlatform.enable") }}</Button>
<Button v-if="!repository.managed" size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeRepository(repository)"><Trash2 class="size-3.5" /></Button>
</div>
</div>
<div class="grid gap-2 lg:grid-cols-[180px_220px_minmax(260px,1fr)_auto]">
<Input v-model="repositoryId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryIdPlaceholder')" />
<Input v-model="repositoryName" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryNamePlaceholder')" />
<Input v-model="repositoryCatalogUrl" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryCatalogUrlPlaceholder')" />
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveRepository"><Plus class="size-3.5" />{{ t("pluginPlatform.addRepository") }}</Button>
</div>
</section>
<details class="group rounded-xl border bg-muted/15" open>
<summary class="flex cursor-pointer list-none items-start gap-3 p-4 marker:content-none">
<div class="rounded-md bg-muted p-2 text-muted-foreground"><Settings2 class="size-4" /></div>
<div class="min-w-0 flex-1">
<div class="text-sm font-medium">{{ t("pluginPlatform.developerOptionsTitle") }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.developerOptionsDescription") }}</div>
</div>
<ChevronRight class="mt-2 size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
</summary>
<div class="space-y-4 border-t p-4">
<div class="flex gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-amber-800 dark:text-amber-200">
<CircleAlert class="mt-0.5 size-4 shrink-0" />
<div class="text-xs leading-5">{{ t("pluginPlatform.developerOptionsWarning") }}</div>
</div>
<div class="flex items-start justify-between gap-4 rounded-lg border bg-background p-3">
<div class="min-w-0">
<Label for="allow-unsigned-plugin-package" class="text-xs font-medium">{{ t("pluginPlatform.allowUnsignedDevelopmentPackage") }}</Label>
<div class="mt-1 text-[11px] leading-5 text-muted-foreground">{{ t("pluginPlatform.allowUnsignedDevelopmentPackageDescription") }}</div>
</div>
<Switch id="allow-unsigned-plugin-package" v-model="allowUnsigned" size="sm" class="mt-0.5 shrink-0" />
</div>
<template v-if="showCustomRepositoryTrustSettings">
<div>
<div class="text-sm font-medium">{{ t("pluginPlatform.repositoryTrustTitle") }}</div>
<div class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.repositoryTrustDescription") }}</div>
</div>
<div v-if="trustedKeys.length" class="divide-y rounded-md border bg-background">
<div v-for="key in trustedKeys" :key="key.keyId" class="flex items-center gap-3 px-3 py-2">
<div class="min-w-0 flex-1">
<div class="text-xs font-medium">{{ key.keyId }}</div>
<div class="truncate font-mono text-[10px] text-muted-foreground" :title="key.publicKey">{{ abbreviatedPublicKey(key.publicKey) }}</div>
</div>
<Button size="icon" variant="ghost" class="size-7 text-destructive" :disabled="operating" @click="removeTrustedKey(key.keyId)"><Trash2 class="size-3.5" /></Button>
</div>
</div>
<div class="grid gap-2 md:grid-cols-[180px_minmax(260px,1fr)_auto]">
<Input v-model="trustedKeyId" class="h-8 text-xs" :placeholder="t('pluginPlatform.repositoryKeyIdPlaceholder')" />
<Input v-model="trustedPublicKey" class="h-8 font-mono text-xs" :placeholder="t('pluginPlatform.repositoryPublicKeyPlaceholder')" />
<Button size="sm" class="h-8 gap-1.5" :disabled="operating" @click="saveTrustedKey"><ShieldCheck class="size-3.5" />{{ t("pluginPlatform.trustRepository") }}</Button>
</div>
</template>
</div>
</details>
</div>
</TabsContent>
</Tabs>
<div v-if="draggingPackage" class="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary/60 bg-primary/5">
<FileUp class="size-8 text-primary" />
<div class="text-sm font-medium text-primary">{{ t("pluginPlatform.dropToInstall") }}</div>
</div>
</div>
</template>
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import { AlertTriangle, ArrowUp, File, FileCode2, Folder, Loader2, RefreshCw } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import * as api from "@/lib/backend/api";
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "@/lib/plugins/pluginFilesystem";
import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
const props = defineProps<{
pluginId: string;
provider: PluginFilesystemProviderContribution;
connectionId?: string;
initialUri?: string;
}>();
const { t } = useI18n();
const currentUri = ref("");
const address = ref("");
const entries = ref<PluginFilesystemEntry[]>([]);
const nextCursor = ref<string>();
const loading = ref(false);
const loadingMore = ref(false);
const error = ref("");
const selected = ref<PluginFilesystemEntry>();
const preview = ref("");
const previewImageUrl = ref("");
const previewKind = ref<"text" | "image" | "binary">("text");
const previewContentType = ref("");
const previewTruncated = ref(false);
const previewLoading = ref(false);
const previewError = ref("");
let listGeneration = 0;
let previewGeneration = 0;
const rootUri = computed(() => pluginFilesystemRootUri(props.provider));
const canRead = computed(() => (props.provider.capabilities || []).includes("read"));
const sortedEntries = computed(() => sortPluginFilesystemEntries(entries.value));
const parentUri = computed(() => pluginFilesystemParentUri(currentUri.value, rootUri.value));
async function load(uri = address.value || currentUri.value || rootUri.value, append = false) {
const targetUri = uri.trim() || rootUri.value;
const generation = ++listGeneration;
if (append) loadingMore.value = true;
else loading.value = true;
error.value = "";
try {
const result = await api.listPluginFilesystemEntries(props.pluginId, props.provider.id, {
connectionId: props.connectionId,
uri: targetUri,
cursor: append ? nextCursor.value : undefined,
limit: 200,
});
if (generation !== listGeneration) return;
currentUri.value = targetUri;
address.value = targetUri;
entries.value = append ? uniquePluginFilesystemEntries([...entries.value, ...result.entries]) : uniquePluginFilesystemEntries(result.entries);
nextCursor.value = result.nextCursor;
if (!append) clearPreview();
} catch (cause) {
if (generation !== listGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (generation === listGeneration) {
loading.value = false;
loadingMore.value = false;
}
}
}
async function activateEntry(entry: PluginFilesystemEntry) {
selected.value = entry;
if (entry.kind === "directory") {
await load(entry.uri);
return;
}
if (!canRead.value || entry.kind !== "file") {
preview.value = "";
previewContentType.value = "";
previewTruncated.value = false;
previewError.value = canRead.value ? t("pluginPlatform.entryCannotPreview") : t("pluginPlatform.providerNoReadCapability");
return;
}
const generation = ++previewGeneration;
previewLoading.value = true;
previewError.value = "";
revokePreviewImage();
preview.value = "";
previewKind.value = "text";
try {
const contentType = (entry.contentType || "").split(";", 1)[0].toLowerCase();
const isImage = contentType.startsWith("image/") || /\.(avif|bmp|gif|jpe?g|png|webp)$/i.test(entry.name);
const result = await api.readPluginFilesystemFile(props.pluginId, props.provider.id, entry.uri, {
connectionId: props.connectionId,
maxBytes: 256 * 1024,
});
if (generation !== previewGeneration) return;
const bytes = Uint8Array.from(atob(result.dataBase64), (character) => character.charCodeAt(0));
const resolvedContentType = (result.contentType || entry.contentType || "application/octet-stream").split(";", 1)[0].toLowerCase();
previewContentType.value = resolvedContentType;
if (isImage || resolvedContentType.startsWith("image/")) {
if (result.truncated) {
previewError.value = t("pluginPlatform.entryCannotPreview");
} else {
previewImageUrl.value = URL.createObjectURL(new Blob([bytes], { type: resolvedContentType }));
previewKind.value = "image";
}
} else if (isTextPreview(entry.name, resolvedContentType)) {
preview.value = new TextDecoder().decode(bytes);
previewKind.value = "text";
} else {
previewKind.value = "binary";
}
previewTruncated.value = result.truncated;
} catch (cause) {
if (generation !== previewGeneration) return;
previewError.value = cause instanceof Error ? cause.message : String(cause);
preview.value = "";
} finally {
if (generation === previewGeneration) previewLoading.value = false;
}
}
function clearPreview() {
previewGeneration += 1;
selected.value = undefined;
preview.value = "";
revokePreviewImage();
previewKind.value = "text";
previewContentType.value = "";
previewTruncated.value = false;
previewLoading.value = false;
previewError.value = "";
}
function revokePreviewImage() {
if (previewImageUrl.value) URL.revokeObjectURL(previewImageUrl.value);
previewImageUrl.value = "";
}
function isTextPreview(name: string, contentType: string) {
return contentType.startsWith("text/") || /^(application\/(json|javascript|xml|yaml)|image\/svg\+xml)$/.test(contentType) || /\.(cjs|css|csv|html?|js|json|md|mjs|toml|ts|tsx|txt|vue|xml|yaml|yml)$/i.test(name);
}
function formatSize(size?: number) {
if (size === undefined) return "";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
onMounted(() => void load(props.initialUri || rootUri.value));
onBeforeUnmount(() => {
revokePreviewImage();
});
watch(
() => [props.pluginId, props.provider.id, props.connectionId, props.initialUri, rootUri.value] as const,
() => void load(props.initialUri || rootUri.value),
);
</script>
<template>
<div class="flex size-full min-h-0 flex-col bg-background">
<div class="flex shrink-0 items-center gap-2 border-b px-3 py-2">
<Button size="icon" variant="ghost" class="size-8" :disabled="!parentUri || loading" :title="t('pluginPlatform.parentDirectory')" @click="parentUri && load(parentUri)">
<ArrowUp class="size-4" />
</Button>
<Input v-model="address" class="h-8 min-w-0 flex-1 font-mono text-xs" :aria-label="t('pluginPlatform.filesystemUri')" @keydown.enter="load(address)" />
<Button size="icon" variant="ghost" class="size-8" :disabled="loading" :title="t('pluginPlatform.refresh')" @click="load(currentUri)">
<Loader2 v-if="loading" class="size-4 animate-spin" />
<RefreshCw v-else class="size-4" />
</Button>
</div>
<div v-if="error" class="m-3 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[minmax(18rem,42%)_minmax(0,1fr)] divide-x">
<div class="flex min-h-0 flex-col">
<div v-if="loading && !entries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFiles") }}</div>
<div v-else-if="!sortedEntries.length" class="flex h-full items-center justify-center text-sm text-muted-foreground">{{ t("pluginPlatform.emptyDirectory") }}</div>
<RecycleScroller v-else class="min-h-0 flex-1 min-w-[28rem] text-sm" :items="sortedEntries" :item-size="46" :buffer="300" key-field="uri">
<template #default="{ item: entry }">
<button type="button" class="grid h-[46px] w-full grid-cols-[minmax(0,1fr)_7rem_10rem] items-center gap-3 border-b px-3 text-left hover:bg-muted/50" :class="selected?.uri === entry.uri ? 'bg-muted' : ''" @click="selected = entry" @dblclick="activateEntry(entry)">
<span class="flex min-w-0 items-center gap-2">
<Folder v-if="entry.kind === 'directory'" class="size-4 shrink-0 text-amber-500" />
<FileCode2 v-else-if="entry.contentType?.startsWith('text/')" class="size-4 shrink-0 text-sky-500" />
<File v-else class="size-4 shrink-0 text-muted-foreground" />
<span class="truncate" :title="entry.name">{{ entry.name }}</span>
</span>
<span class="text-right text-xs tabular-nums text-muted-foreground">{{ entry.kind === "directory" ? "" : formatSize(entry.size) }}</span>
<span class="truncate text-xs text-muted-foreground" :title="entry.modifiedAt">{{ entry.modifiedAt || "" }}</span>
</button>
</template>
</RecycleScroller>
<div v-if="nextCursor" class="flex shrink-0 justify-center p-3">
<Button size="sm" variant="outline" :disabled="loadingMore" @click="load(currentUri, true)"> <Loader2 v-if="loadingMore" class="mr-2 size-3.5 animate-spin" />{{ t("pluginPlatform.loadMore") }} </Button>
</div>
</div>
<div class="flex min-h-0 min-w-0 flex-col">
<div v-if="!selected" class="m-auto max-w-sm px-6 text-center text-sm text-muted-foreground">{{ t("pluginPlatform.fileManagerHint") }}</div>
<template v-else>
<div class="shrink-0 border-b px-4 py-3">
<div class="truncate text-sm font-medium" :title="selected.name">{{ selected.name }}</div>
<div class="mt-1 truncate font-mono text-[11px] text-muted-foreground" :title="selected.uri">{{ selected.uri }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<div v-if="previewLoading" class="flex h-full items-center justify-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingPreview") }}</div>
<div v-else-if="previewError" class="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangle class="mt-0.5 size-4 shrink-0" />{{ previewError }}</div>
<template v-else-if="previewKind === 'image' && previewImageUrl">
<img :src="previewImageUrl" :alt="selected.name" class="max-h-full max-w-full rounded-md object-contain" />
</template>
<template v-else-if="previewKind === 'text' && preview">
<div class="mb-2 flex items-center justify-between gap-3 text-[11px] text-muted-foreground">
<span>{{ previewContentType }}</span>
<span v-if="previewTruncated">{{ t("pluginPlatform.previewTruncated") }}</span>
</div>
<pre class="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-3 font-mono text-xs leading-5">{{ preview }}</pre>
</template>
<div v-else-if="previewKind === 'binary'" class="m-auto max-w-sm text-center text-sm text-muted-foreground">{{ t("pluginPlatform.entryCannotPreview") }}</div>
<div v-else class="text-sm text-muted-foreground">{{ t("pluginPlatform.previewSelectedFileHint") }}</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import PluginFileManager from "@/components/plugins/PluginFileManager.vue";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import type { InstalledPlugin } from "@/types/database";
import { useI18n } from "vue-i18n";
const props = defineProps<{
pluginId: string;
providerId: string;
connectionId?: string;
rootUri?: string;
initialUri?: string;
}>();
const { t, locale: appLocale } = useI18n();
const plugins = ref<InstalledPlugin[]>([]);
const loading = ref(true);
const error = ref("");
let loadGeneration = 0;
const entry = computed(() =>
createFrontendPluginRegistry(plugins.value, appLocale.value)
.listFilesystemProviders()
.find((candidate) => candidate.plugin.manifest.id === props.pluginId && candidate.contribution.id === props.providerId),
);
const provider = computed(() => (entry.value ? { ...entry.value.contribution, root_uri: props.rootUri || entry.value.contribution.root_uri } : undefined));
async function load() {
const generation = ++loadGeneration;
loading.value = true;
error.value = "";
try {
const installed = await api.listPlugins();
if (generation !== loadGeneration) return;
plugins.value = installed;
if (!entry.value) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId: props.pluginId, providerId: props.providerId }));
} catch (cause) {
if (generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (generation === loadGeneration) loading.value = false;
}
}
onMounted(() => void load());
watch(
() => [props.pluginId, props.providerId],
() => void load(),
);
</script>
<template>
<div class="flex size-full min-h-0">
<div v-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingFilesystem") }}</div>
<div v-else-if="error || !provider" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error || t("pluginPlatform.filesystemUnavailableFallback") }}</span>
</div>
<PluginFileManager v-else class="min-h-0 flex-1" :plugin-id="pluginId" :provider="provider" :connection-id="connectionId" :initial-uri="initialUri" />
</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp, nextTick, type App } from "vue";
import PluginIcon from "./PluginIcon.vue";
const { readPluginAssetMock } = vi.hoisted(() => ({
readPluginAssetMock: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => ({
readPluginAsset: readPluginAssetMock,
}));
const mountedApps: App[] = [];
async function mountIcon(icon?: string) {
const container = document.createElement("div");
document.body.appendChild(container);
const app = createApp(PluginIcon, { pluginId: "example.plugin", icon });
app.mount(container);
mountedApps.push(app);
await Promise.resolve();
await nextTick();
return container;
}
beforeEach(() => {
readPluginAssetMock.mockReset();
vi.stubGlobal("URL", {
...URL,
createObjectURL: vi.fn(() => "blob:plugin-icon"),
revokeObjectURL: vi.fn(),
});
});
afterEach(() => {
for (const app of mountedApps.splice(0)) app.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("PluginIcon", () => {
it("renders a developer-provided image asset", async () => {
readPluginAssetMock.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+", etag: "icon" });
const container = await mountIcon("assets/icon.svg");
expect(readPluginAssetMock).toHaveBeenCalledWith("example.plugin", "assets/icon.svg");
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:plugin-icon");
});
it("falls back when the asset cannot be loaded", async () => {
readPluginAssetMock.mockRejectedValue(new Error("missing"));
const container = await mountIcon("assets/missing.svg");
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
it("uses the fallback without reading an undeclared asset", async () => {
const container = await mountIcon();
expect(readPluginAssetMock).not.toHaveBeenCalled();
expect(container.querySelector("svg")).not.toBeNull();
});
it("renders marketplace icon URLs without reading installed assets", async () => {
const container = await mountIcon("https://plugins.example.com/icon.svg");
expect(readPluginAssetMock).not.toHaveBeenCalled();
expect(container.querySelector("img")?.getAttribute("src")).toBe("https://plugins.example.com/icon.svg");
});
});
@@ -0,0 +1,72 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, watch } from "vue";
import { PlugZap } from "@lucide/vue";
import * as api from "@/lib/backend/api";
const props = defineProps<{
pluginId: string;
icon?: string;
}>();
const objectUrl = ref("");
const failed = ref(false);
let ownsObjectUrl = false;
let requestSequence = 0;
function clearObjectUrl() {
if (!objectUrl.value) return;
if (ownsObjectUrl) URL.revokeObjectURL(objectUrl.value);
objectUrl.value = "";
ownsObjectUrl = false;
}
function showFallback() {
clearObjectUrl();
failed.value = true;
}
watch(
() => [props.pluginId, props.icon] as const,
async ([pluginId, icon]) => {
const sequence = ++requestSequence;
clearObjectUrl();
if (!pluginId || !icon) {
failed.value = true;
return;
}
failed.value = false;
if (/^https?:\/\//i.test(icon)) {
objectUrl.value = icon;
return;
}
try {
const asset = await api.readPluginAsset(pluginId, icon);
if (!asset.contentType.startsWith("image/")) throw new Error("Plugin icon is not an image");
const binary = atob(asset.dataBase64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
const url = URL.createObjectURL(new Blob([bytes], { type: asset.contentType }));
if (sequence !== requestSequence) {
URL.revokeObjectURL(url);
return;
}
objectUrl.value = url;
ownsObjectUrl = true;
} catch {
if (sequence === requestSequence) failed.value = true;
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
requestSequence += 1;
clearObjectUrl();
});
</script>
<template>
<span class="inline-flex shrink-0 items-center justify-center">
<img v-if="objectUrl && !failed" :src="objectUrl" alt="" class="size-full object-contain" @error="showFallback" />
<PlugZap v-else aria-hidden="true" class="size-full text-violet-500" />
</span>
</template>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { PluginHostBridge, pluginSandboxDocument, type PluginBridgeTheme, type PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
import { useI18n } from "vue-i18n";
import { useTheme } from "@/composables/useTheme";
const props = withDefaults(
defineProps<{
plugin: InstalledPlugin;
contribution: PluginWorkbenchContribution;
context?: PluginWorkbenchContext;
}>(),
{ context: () => ({}) },
);
const emit = defineEmits<{
ready: [];
error: [message: string];
openWorkbench: [pluginId: string, contributionId: string, context?: PluginWorkbenchContext];
openFilesystem: [pluginId: string, providerId: string, context?: PluginWorkbenchContext];
}>();
const { t, locale: appLocale } = useI18n();
const { isDark, activeCustomUiColors } = useTheme();
const iframe = ref<HTMLIFrameElement>();
const source = ref("");
const loading = ref(true);
const error = ref("");
let bridge: PluginHostBridge | undefined;
let unsubscribeEvents: (() => void) | undefined;
let disposed = false;
let loadGeneration = 0;
const title = computed(() => `${props.plugin.manifest.name} · ${props.contribution.label}`);
/** Collect resolved DBX design tokens so the sandbox can theme itself with the same values. */
function currentBridgeTheme(): PluginBridgeTheme {
const tokens: Record<string, string> = {};
if (typeof document !== "undefined") {
const style = getComputedStyle(document.documentElement);
for (const name of style) {
if (!name.startsWith("--") || name.startsWith("--dbx-")) continue;
if (/^--(color|radius|font)/.test(name)) {
const value = style.getPropertyValue(name).trim();
if (value) tokens[name] = value;
}
}
}
return { appearance: isDark.value ? "dark" : "light", tokens };
}
function createBridge() {
bridge = new PluginHostBridge(
props.plugin,
props.contribution,
props.context,
() => iframe.value?.contentWindow || null,
{
invoke: api.invokePlugin,
notify: api.notifyPlugin,
sendBinary: api.sendPluginBinary,
readAsset: api.readPluginUiAsset,
openWorkbench: async (pluginId, contributionId, context) => emit("openWorkbench", pluginId, contributionId, context),
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
},
appLocale.value,
currentBridgeTheme(),
);
}
function localUiAssetPath(source: string): string | undefined {
const trimmed = source.trim();
if (!trimmed || /^(?:blob:|data:|https?:|\/\/)/i.test(trimmed)) return undefined;
try {
const resolved = new URL(trimmed, "https://dbx-plugin.invalid/");
if (resolved.origin !== "https://dbx-plugin.invalid") return undefined;
const path = decodeURIComponent(resolved.pathname).replace(/^\/+/, "");
if (!path || path.split("/").some((segment) => segment === "..")) return undefined;
return path;
} catch {
return undefined;
}
}
async function inlineLocalUiAssets(html: string, pluginId: string): Promise<string> {
const document = new DOMParser().parseFromString(html, "text/html");
const resources = [...document.querySelectorAll("script[src], link[rel='stylesheet'][href]")];
for (const resource of resources) {
const source = resource.getAttribute(resource.tagName === "SCRIPT" ? "src" : "href");
const path = source ? localUiAssetPath(source) : undefined;
if (!path) continue;
const asset = await api.readPluginUiAsset(pluginId, path);
const content = new TextDecoder().decode(Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0)));
if (resource.tagName === "SCRIPT") {
const script = document.createElement("script");
for (const attribute of [...resource.attributes]) {
if (attribute.name !== "src") script.setAttribute(attribute.name, attribute.value);
}
script.textContent = content;
resource.replaceWith(script);
} else {
const style = document.createElement("style");
style.textContent = content;
resource.replaceWith(style);
}
}
return document.documentElement.outerHTML;
}
async function loadWorkbench() {
const generation = ++loadGeneration;
bridge = undefined;
loading.value = true;
error.value = "";
try {
if (!props.plugin.compatibility.compatible) throw new Error((props.plugin.compatibility.errors || []).join("; ") || t("pluginPlatform.pluginIncompatible"));
const asset = await api.readPluginUiEntry(props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
const bytes = Uint8Array.from(atob(asset.dataBase64), (character) => character.charCodeAt(0));
const html = await inlineLocalUiAssets(new TextDecoder().decode(bytes), props.plugin.manifest.id);
if (disposed || generation !== loadGeneration) return;
source.value = pluginSandboxDocument(html, props.plugin.manifest.permissions);
await nextTick();
if (disposed || generation !== loadGeneration) return;
createBridge();
} catch (cause) {
if (disposed || generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
emit("error", error.value);
} finally {
if (!disposed && generation === loadGeneration) loading.value = false;
}
}
function onMessage(event: MessageEvent) {
bridge?.handleWindowMessage(event);
}
function onFrameLoad() {
bridge?.sendInit();
emit("ready");
}
onMounted(async () => {
window.addEventListener("message", onMessage);
const unsubscribe = await api.subscribePluginEvents(
(event) => bridge?.forwardEvent(event),
(event) => bridge?.forwardBinary(event),
);
if (disposed) {
unsubscribe();
return;
}
unsubscribeEvents = unsubscribe;
await loadWorkbench();
});
// Identity changes require rebuilding the sandbox document; context and locale
// changes are pushed through the bridge so plugin UI state survives them.
watch(
() => [props.plugin.manifest.id, props.plugin.manifest.version, props.contribution.id] as const,
() => void loadWorkbench(),
);
watch(
() => props.context,
(context) => bridge?.updateContext(context ?? {}),
{ deep: true },
);
watch(appLocale, (locale) => bridge?.updateLocale(locale));
watch([isDark, activeCustomUiColors], () => bridge?.updateTheme(currentBridgeTheme()), { deep: true });
onBeforeUnmount(() => {
disposed = true;
loadGeneration += 1;
bridge = undefined;
window.removeEventListener("message", onMessage);
unsubscribeEvents?.();
});
</script>
<template>
<div class="relative flex size-full min-h-40 overflow-hidden bg-background">
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/80 text-sm text-muted-foreground backdrop-blur-sm">
<Loader2 class="mr-2 size-4 animate-spin" />
{{ t("pluginPlatform.loadingTitle", { title }) }}
</div>
<div v-else-if="error" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error }}</span>
</div>
<iframe v-else ref="iframe" :title="title" :srcdoc="source" sandbox="allow-scripts" referrerpolicy="no-referrer" class="size-full border-0 bg-transparent" @load="onFrameLoad" />
</div>
</template>
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { AlertTriangle, Loader2 } from "@lucide/vue";
import PluginWorkbenchHost from "@/components/plugins/PluginWorkbenchHost.vue";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import type { PluginWorkbenchContext } from "@/lib/plugins/pluginHostBridge";
import type { InstalledPlugin } from "@/types/database";
import { useQueryStore } from "@/stores/queryStore";
import { useI18n } from "vue-i18n";
const props = defineProps<{
pluginId: string;
contributionId: string;
context?: PluginWorkbenchContext;
}>();
const { t, locale: appLocale } = useI18n();
const queryStore = useQueryStore();
const plugins = ref<InstalledPlugin[]>([]);
const loading = ref(true);
const error = ref("");
let loadGeneration = 0;
const entry = computed(() => createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(props.pluginId, props.contributionId));
async function load() {
const generation = ++loadGeneration;
loading.value = true;
error.value = "";
try {
const installed = await api.listPlugins();
if (generation !== loadGeneration) return;
plugins.value = installed;
if (!entry.value) throw new Error(t("pluginPlatform.workbenchUnavailable", { pluginId: props.pluginId, contributionId: props.contributionId }));
} catch (cause) {
if (generation !== loadGeneration) return;
error.value = cause instanceof Error ? cause.message : String(cause);
} finally {
if (generation === loadGeneration) loading.value = false;
}
}
function openWorkbench(pluginId: string, contributionId: string, context?: PluginWorkbenchContext) {
const target = createFrontendPluginRegistry(plugins.value, appLocale.value).findWorkbench(pluginId, contributionId);
queryStore.openPluginWorkbench(pluginId, contributionId, { title: target?.contribution.label || contributionId, context });
}
function openFilesystem(pluginId: string, providerId: string, context?: PluginWorkbenchContext) {
const target = createFrontendPluginRegistry(plugins.value, appLocale.value)
.listFilesystemProviders()
.find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
if (!target) throw new Error(t("pluginPlatform.filesystemUnavailable", { pluginId, providerId }));
queryStore.openPluginFilesystem(pluginId, providerId, {
title: target.contribution.label,
connectionId: typeof context?.connectionId === "string" ? context.connectionId : undefined,
rootUri: target.contribution.root_uri,
currentUri: typeof context?.uri === "string" ? context.uri : undefined,
});
}
onMounted(() => void load());
watch(
() => [props.pluginId, props.contributionId],
() => void load(),
);
</script>
<template>
<div class="flex size-full min-h-0">
<div v-if="loading" class="m-auto flex items-center text-sm text-muted-foreground"><Loader2 class="mr-2 size-4 animate-spin" />{{ t("pluginPlatform.loadingWorkbench") }}</div>
<div v-else-if="error || !entry" class="m-auto flex max-w-lg items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm text-destructive">
<AlertTriangle class="mt-0.5 size-4 shrink-0" />
<span>{{ error || t("pluginPlatform.workbenchUnavailableFallback") }}</span>
</div>
<PluginWorkbenchHost v-else class="min-h-0 flex-1" :plugin="entry.plugin" :contribution="entry.contribution" :context="context" @open-workbench="openWorkbench" @open-filesystem="openFilesystem" />
</div>
</template>
@@ -28,6 +28,7 @@ import {
Plug,
Unplug,
Pin,
PlugZap,
ArrowRightLeft,
Download,
Eye,
@@ -70,6 +71,8 @@ import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors";
import { useToast } from "@/composables/useToast";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import type { InstalledPlugin } from "@/types/database";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import * as api from "@/lib/backend/api";
@@ -339,7 +342,7 @@ import {
type DuplicateStructureSource,
} from "./sidebarTreeDialogState";
const { t } = useI18n();
const { t, locale: appLocale } = useI18n();
const connectionStore = useConnectionStore();
@@ -350,6 +353,15 @@ const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore();
const { toast } = useToast();
const installedPlugins = ref<InstalledPlugin[]>([]);
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
void api.listPlugins().then(
(plugins) => {
installedPlugins.value = plugins.filter((plugin) => plugin.compatibility.compatible);
},
() => {},
);
const { highlight } = useSqlHighlighter();
@@ -924,6 +936,9 @@ async function toggle(requestId = beginNavigationRequest()) {
await connectionStore.loadNacosNamespaces(node.connectionId, treeLoadSearchOptions);
} else if (config?.db_type === "mqtt") {
await connectionStore.loadMqttTopics(node.connectionId);
} else if (config?.db_type === "plugin") {
await queryStore.openPluginConnection(node.connectionId);
return;
} else {
await connectionStore.loadDatabases(node.connectionId, treeLoadSearchOptions);
}
@@ -1628,13 +1643,16 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean
const eventCreateRequestId = openEventEditor === "create" && node.type === "group-events" ? ++mysqlEventCreateRequestSeq : undefined;
const objectFilter = node.type === "event" || node.type === "group-events" ? "events" : undefined;
const connection = connectionStore.getConfig(node.connectionId);
if (!connection) return;
if (connection.db_type === "plugin") {
await queryStore.openPluginConnection(node.connectionId);
return;
}
if (hasTreeNodeDatabaseContext(node)) {
queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId);
return;
}
const connection = connectionStore.getConfig(node.connectionId);
if (!connection) return;
const options = await getDatabaseOptions(node.connectionId);
const database = resolveDefaultDatabase(connection, options);
if (database) {
@@ -6453,9 +6471,40 @@ function treeItemMenuItems(): ContextMenuItem[] {
items.push({ label: t("contextMenu.copyName"), action: copyName, icon: Copy, shortcut: shortcutCopyName.value });
}
appendPluginConnectionMenuItems(items, node);
return items;
}
/** Plugin-contributed native menu entries for saved connections. */
function appendPluginConnectionMenuItems(items: ContextMenuItem[], node: TreeNode) {
if (node.type !== "connection") return;
const pluginItems = sidebarPluginRegistry.value.listContextMenuItems("connection");
if (pluginItems.length === 0) return;
const config = node.connectionId ? connectionStore.getConfig(node.connectionId) : undefined;
if (!config) return;
items.push({ label: "", separator: true });
for (const { plugin, contribution } of pluginItems) {
items.push({
label: contribution.label,
icon: PlugZap,
action: () => {
api
.invokePlugin(plugin.manifest.id, `contextMenu/${contribution.id}`, {
connection: { id: config.id, dbType: config.db_type, name: config.name, database: config.database || "" },
})
.then((result) => {
const message = (result as { message?: unknown } | null | undefined)?.message;
if (typeof message === "string" && message.trim()) toast(message, 4000);
})
.catch((error: unknown) => {
toast(String((error as Error)?.message || error), 5000);
});
},
});
}
}
function activateRuntimeNode(node: TreeNode) {
activeNode.value = node;
}
@@ -4,6 +4,8 @@ import { useI18n } from "vue-i18n";
import { Eye, EyeOff } from "@lucide/vue";
import { Input } from "@/components/ui/input";
defineOptions({ inheritAttrs: false });
const model = defineModel<string>();
const props = withDefaults(
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const listeners = new Map<string, (event: { payload: unknown }) => void>();
const unlisten = vi.fn();
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (event: string, handler: (event: { payload: unknown }) => void) => {
listeners.set(event, handler);
return unlisten;
}),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ connections: [], initFromDisk: vi.fn(), getConfig: vi.fn(), ensureConnected: vi.fn() }),
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({ createTab: vi.fn(), showExecutedQueryResults: vi.fn() }),
}));
import { useTauriEvents } from "@/composables/useTauriEvents";
describe("useTauriEvents", () => {
beforeEach(() => {
listeners.clear();
unlisten.mockClear();
});
it("routes the native macOS close-tab menu event to the active surface", async () => {
const closeActiveSurface = vi.fn();
const events = useTauriEvents({
openTableTarget: vi.fn(),
openSqlFilePath: vi.fn(),
openDbFilePath: vi.fn(),
openConnectionDeepLink: vi.fn(),
closeActiveSurface,
});
events.setupTauriListeners();
await vi.waitFor(() => expect(listeners.has("dbx-close-active-tab")).toBe(true));
listeners.get("dbx-close-active-tab")!({ payload: undefined });
expect(closeActiveSurface).toHaveBeenCalledOnce();
events.cleanupTauriListeners();
expect(unlisten).toHaveBeenCalled();
});
});
@@ -9,6 +9,7 @@ export function useTauriEvents(deps: {
openDbFilePath: (path: string) => Promise<void>;
openConnectionDeepLink: (url: string) => Promise<void>;
openAiConfigDeepLink: (url: string) => Promise<void>;
closeActiveSurface: () => void;
}) {
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
@@ -116,6 +117,10 @@ export function useTauriEvents(deps: {
console.error("[DBX] dbx-open-ai-config-links error:", e);
}
}).then((unlisten) => unlistenHandles.push(unlisten));
listen("dbx-close-active-tab", () => {
deps.closeActiveSurface();
}).then((unlisten) => unlistenHandles.push(unlisten));
})
.catch(() => {});
}
+119
View File
@@ -164,6 +164,114 @@ export default {
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "Open with {label}",
centerTitle: "Plugin Center",
centerDescription: "Discover optional DBX capabilities in one marketplace, then manage installed plugins and repositories separately.",
marketplace: "Marketplace",
installed: "Installed",
settings: "Settings",
searchMarketplace: "Search plugins, publishers, or tags",
allRepositories: "All repositories",
loadingMarketplace: "Loading plugin marketplace",
noMarketplacePlugins: "No plugins to show",
noMarketplacePluginsDescription: "Configure a repository in Settings, or adjust the current search and filters.",
verified: "Verified",
noDescription: "No description provided.",
permissionsCount: "{count} permissions",
unsupportedTarget: "Not available for {target}",
installedVersion: "Installed v{version}",
licenseUnknown: "License not specified",
sourceRepository: "Source repository",
pluginHomepage: "Homepage",
marketplaceStatus: {
install: "Install",
installed: "Installed",
update: "Update",
unsupported: "Unsupported",
},
updateSuccess: "Updated {name} to {version}",
noInstalledPlugins: "No plugins installed",
browseMarketplace: "Browse Marketplace",
localInstallTitle: "Install a local package",
localInstallDescription: "Install a signed .dbxp obtained outside the marketplace. DBX verifies package integrity and repository signatures automatically.",
repositoriesTitle: "Plugin repositories",
repositoriesDescription: "The official source is signed and managed by DBX. Custom repositories use one repository-level trust key configured below.",
repositoryFieldsRequired: "Repository ID, name, and catalog URL are required",
repositorySaved: "Plugin repository '{name}' saved",
removeRepositoryConfirm: "Remove plugin repository '{name}'? Installed plugins remain installed.",
repositoryRemoved: "Plugin repository '{name}' removed",
repositoryKind: {
official: "Official",
custom: "Custom",
enterprise: "Enterprise",
},
repositoryNotConfigured: "Catalog URL is not configured in this build",
enabled: "Enabled",
disabled: "Disabled",
enable: "Enable",
disable: "Disable",
repositoryIdPlaceholder: "Repository ID",
repositoryNamePlaceholder: "Repository name",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "Add repository",
signedPackagesVerifiedAutomatically: "Signatures are verified automatically",
developerOptionsTitle: "Third-party and developer options",
developerOptionsDescription: "Use only for custom repositories or local plugin development.",
developerOptionsWarning: "Unsigned plugins can run native code with your user permissions. Enable this only for packages you built and trust.",
connectionManagedInDialog: "Create and edit connections in the unified New Connection dialog. This page only manages plugins and shows their capabilities.",
allowUnsignedDevelopmentPackage: "Allow unsigned development packages",
allowUnsignedDevelopmentPackageDescription: "Applies only to manual .dbxp installation and never relaxes official marketplace verification.",
installPackage: "Install .dbxp",
dropToInstall: "Drop to install the plugin package",
dropInstallHint: "You can also drop a .dbxp file onto this page",
packageFileType: "DBX plugin package",
repositoryTrustTitle: "Custom repository trust",
repositoryTrustDescription: "A custom repository operator signs all approved packages with its repository key. Obtain that public key through a separate trusted channel.",
repositoryKeyAdded: "Repository signing key '{keyId}' added",
removeRepositoryKeyConfirm: "Remove repository signing key '{keyId}'? Installed plugins remain installed.",
repositoryKeyRemoved: "Repository signing key '{keyId}' removed",
repositoryKeyIdPlaceholder: "Repository key ID",
repositoryPublicKeyPlaceholder: "Base64 Ed25519 repository public key (32 bytes)",
trustRepository: "Trust repository",
noInstalledPluginsDescription: "Install a signed .dbxp, or explicitly enable unsigned packages for local development.",
compatible: "Compatible",
blocked: "Blocked",
rollback: "Rollback",
uninstall: "Uninstall",
rollbackConfirm: "Roll back this plugin to the previous installed version?",
rollbackSuccess: "Rolled back to {version}",
uninstallConfirm: "Uninstall {name}?",
uninstallSuccess: "Uninstalled {name}",
installSuccess: "Installed {name} {version}",
selectConnectionProvider: "Select a connection provider first",
connectionProvider: "Connection provider",
newConnection: "New",
workbenches: "Workbenches",
filesystemProviders: "Filesystem providers",
open: "Open",
browse: "Browse",
parentDirectory: "Parent directory",
filesystemUri: "Filesystem URI",
refresh: "Refresh",
loadingFiles: "Loading files",
emptyDirectory: "This directory is empty.",
loadMore: "Load more",
fileManagerHint: "Double-click a directory to open it, or a file to preview up to 256 KB.",
loadingPreview: "Loading preview",
previewTruncated: "Preview truncated at 256 KB",
previewSelectedFileHint: "Double-click the selected file to load its preview.",
entryCannotPreview: "This entry cannot be previewed.",
providerNoReadCapability: "The provider does not declare read capability.",
filesystemUnavailable: "Plugin filesystem '{pluginId}/{providerId}' is unavailable",
loadingFilesystem: "Loading plugin filesystem",
filesystemUnavailableFallback: "Plugin filesystem is unavailable",
workbenchUnavailable: "Plugin workbench '{pluginId}/{contributionId}' is unavailable",
loadingWorkbench: "Loading plugin workbench",
workbenchUnavailableFallback: "Plugin workbench is unavailable",
pluginIncompatible: "Plugin is incompatible",
loadingTitle: "Loading {title}",
},
auth: {
rateLimited: "Please try again in {seconds}s",
setupTitle: "Set up access password",
@@ -229,6 +337,7 @@ export default {
sqlOpenFailed: "Failed to open file: {message}",
sqlSaveFailed: "Failed to save file: {message}",
driverManager: "Driver Manager",
pluginCenter: "Plugin Center",
updatableDriverCount: "Updatable driver count",
mcpUpdateAvailable: "MCP server update available",
blockDangerousRedisCommands: "Block dangerous commands",
@@ -1005,6 +1114,9 @@ export default {
databaseCategoryTimeseries: "Time series",
databaseCategoryMq: "Message queues",
databaseCategoryRegistryConfig: "Registry/Config",
databaseCategoryPlugins: "Plugins",
pluginProviderUnavailable: "This plugin connection provider is unavailable. Reinstall or enable the plugin, then try again.",
pluginRequiredField: "{field} is required",
jdbcConnection: "JDBC connection",
iconView: "Icon view",
listView: "List view",
@@ -7961,6 +8073,13 @@ export default {
localInstall: "Local install",
localInstallHint: "For air-gapped machines: on another computer with internet access, download the official dbx-jdbc-plugin-*.zip from the DBX GitHub Releases page, transfer it here, and select it — no network access is needed on this machine.",
runtimeDrivers: "Runtime",
pluginsTab: "Plugins",
pluginContributionsTitle: "Frontend plugin contributions",
pluginContributionsDescription: "Installed plugins can describe connection forms and other UI contributions without injecting code into the main DBX window.",
noInstalledPlugins: "No installed plugins",
noInstalledPluginsDescription: "Install a backend plugin to preview its frontend contributions here.",
pluginHostPreview: "Host API preview",
pluginNoFrontendContribution: "This plugin does not declare a frontend contribution yet.",
storageTab: "Storage",
runtimeTitle: "Driver runtime",
runtimeHint: "Runtime metrics are collected only while this panel is open.",
+110
View File
@@ -166,6 +166,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "Abrir con {label}",
centerTitle: "Centro de plugins",
centerDescription: "Descubre capacidades opcionales de DBX en un marketplace unificado y administra por separado los plugins instalados y los repositorios.",
marketplace: "Marketplace",
installed: "Instalados",
settings: "Ajustes",
searchMarketplace: "Buscar plugins, editores o etiquetas",
allRepositories: "Todos los repositorios",
loadingMarketplace: "Cargando marketplace de plugins",
noMarketplacePlugins: "No hay plugins para mostrar",
noMarketplacePluginsDescription: "Configura un repositorio en Ajustes o cambia la búsqueda y los filtros.",
verified: "Verificado",
noDescription: "Sin descripción.",
permissionsCount: "{count} permisos",
unsupportedTarget: "No disponible para {target}",
installedVersion: "Instalado v{version}",
licenseUnknown: "Licencia no especificada",
sourceRepository: "Repositorio de código fuente",
pluginHomepage: "Página principal",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Actualizar", unsupported: "No compatible" },
updateSuccess: "Se actualizó {name} a {version}",
noInstalledPlugins: "No hay plugins instalados",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar un paquete local",
localInstallDescription: "Instala un .dbxp firmado obtenido fuera del marketplace. DBX verifica automáticamente la integridad y la firma del repositorio.",
repositoriesTitle: "Repositorios de plugins",
repositoriesDescription: "DBX administra la fuente oficial. Los repositorios propios o de terceros usan la configuración de confianza del repositorio.",
repositoryFieldsRequired: "El ID, el nombre y la URL del catálogo son obligatorios",
repositorySaved: "Se guardó el repositorio de plugins '{name}'",
removeRepositoryConfirm: "¿Eliminar el repositorio de plugins '{name}'? Los plugins instalados permanecerán.",
repositoryRemoved: "Se eliminó el repositorio de plugins '{name}'",
repositoryKind: { official: "Oficial", custom: "Personalizado", enterprise: "Empresarial" },
repositoryNotConfigured: "La URL del catálogo no está configurada en esta compilación",
enabled: "Activado",
disabled: "Desactivado",
enable: "Activar",
disable: "Desactivar",
repositoryIdPlaceholder: "ID del repositorio",
repositoryNamePlaceholder: "Nombre del repositorio",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "Añadir repositorio",
signedPackagesVerifiedAutomatically: "Integridad y firmas verificadas automáticamente",
developerOptionsTitle: "Opciones de terceros y desarrollo",
developerOptionsDescription: "Úsalas solo para repositorios personalizados o desarrollo local de plugins.",
developerOptionsWarning: "Los plugins sin firma pueden ejecutar código nativo con tus permisos de usuario. Actívalo solo para paquetes creados y verificados por ti.",
connectionManagedInDialog: "Crea y edita conexiones en el cuadro unificado Nueva conexión. Esta página solo administra plugins y muestra sus capacidades.",
allowUnsignedDevelopmentPackage: "Permitir paquetes de desarrollo sin firmar",
allowUnsignedDevelopmentPackageDescription: "Solo afecta a la instalación manual de .dbxp y nunca reduce la verificación del marketplace oficial.",
installPackage: "Instalar .dbxp",
dropToInstall: "Suelta para instalar el paquete del plugin",
dropInstallHint: "También puedes soltar un archivo .dbxp en esta página",
packageFileType: "Paquete de plugin de DBX",
repositoryTrustTitle: "Confianza del repositorio personalizado",
repositoryTrustDescription: "El operador del repositorio firma todos los paquetes aprobados con una única clave. Obtén la clave pública mediante un canal independiente y confiable.",
repositoryKeyAdded: "Se añadió la clave de firma del repositorio '{keyId}'",
removeRepositoryKeyConfirm: "¿Quitar la clave de firma del repositorio '{keyId}'? Los plugins instalados permanecerán instalados.",
repositoryKeyRemoved: "Se quitó la clave de firma del repositorio '{keyId}'",
repositoryKeyIdPlaceholder: "ID de clave del repositorio",
repositoryPublicKeyPlaceholder: "Clave pública Ed25519 del repositorio en Base64 (32 bytes)",
trustRepository: "Confiar en el repositorio",
noInstalledPluginsDescription: "Instala un .dbxp firmado o permite explícitamente paquetes sin firmar para desarrollo local.",
compatible: "Compatible",
blocked: "Bloqueado",
rollback: "Revertir",
uninstall: "Desinstalar",
rollbackConfirm: "¿Revertir este plugin a la versión instalada anterior?",
rollbackSuccess: "Se revirtió a {version}",
uninstallConfirm: "¿Desinstalar {name}?",
uninstallSuccess: "Se desinstaló {name}",
installSuccess: "Se instaló {name} {version}",
selectConnectionProvider: "Selecciona primero un proveedor de conexión",
connectionProvider: "Proveedor de conexión",
newConnection: "Nueva",
workbenches: "Áreas de trabajo",
filesystemProviders: "Proveedores de sistema de archivos",
open: "Abrir",
browse: "Explorar",
parentDirectory: "Directorio superior",
filesystemUri: "URI del sistema de archivos",
refresh: "Actualizar",
loadingFiles: "Cargando archivos",
emptyDirectory: "Este directorio está vacío.",
loadMore: "Cargar más",
fileManagerHint: "Haz doble clic en un directorio para abrirlo o en un archivo para previsualizar hasta 256 KB.",
loadingPreview: "Cargando vista previa",
previewTruncated: "Vista previa truncada a 256 KB",
previewSelectedFileHint: "Haz doble clic en el archivo seleccionado para cargar su vista previa.",
entryCannotPreview: "No se puede previsualizar este elemento.",
providerNoReadCapability: "El proveedor no declara capacidad de lectura.",
filesystemUnavailable: "El sistema de archivos del plugin '{pluginId}/{providerId}' no está disponible",
loadingFilesystem: "Cargando sistema de archivos del plugin",
filesystemUnavailableFallback: "El sistema de archivos del plugin no está disponible",
workbenchUnavailable: "El área de trabajo del plugin '{pluginId}/{contributionId}' no está disponible",
loadingWorkbench: "Cargando área de trabajo del plugin",
workbenchUnavailableFallback: "El área de trabajo del plugin no está disponible",
pluginIncompatible: "El plugin no es compatible",
loadingTitle: "Cargando {title}",
},
auth: {
rateLimited: "Vuelva a intentarlo en {seconds} s",
setupTitle: "Configurar contraseña de acceso",
@@ -231,6 +330,7 @@ export default withEnglishFallback({
sqlOpenFailed: "Error al abrir el archivo: {message}",
sqlSaveFailed: "Error al guardar el archivo: {message}",
driverManager: "Administrador de drivers",
pluginCenter: "Centro de plugins",
updatableDriverCount: "Cantidad de drivers actualizables",
mcpUpdateAvailable: "Actualización del servidor MCP disponible",
blockDangerousRedisCommands: "Bloquear comandos peligrosos",
@@ -815,6 +915,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "Temporal",
databaseCategoryMq: "Colas",
databaseCategoryRegistryConfig: "Registro/config.",
databaseCategoryPlugins: "Plugins",
pluginProviderUnavailable: "Este proveedor de conexión del plugin no está disponible. Reinstala o habilita el plugin e inténtalo de nuevo.",
pluginRequiredField: "{field} es obligatorio",
jdbcConnection: "Conexión JDBC",
iconView: "Vista de íconos",
listView: "Vista de lista",
@@ -7518,6 +7621,13 @@ export default withEnglishFallback({
localInstall: "Instalación local",
localInstallHint: "Para entornos sin acceso a internet: en otro equipo con conexión, descarga el dbx-jdbc-plugin-*.zip oficial desde la página de Releases de DBX en GitHub, transfiérelo a esta máquina y selecciónalo; no se necesita acceso a la red en este equipo.",
runtimeDrivers: "Runtime",
pluginsTab: "Plugins",
pluginContributionsTitle: "Contribuciones de frontend de plugins",
pluginContributionsDescription: "Los plugins instalados pueden describir formularios de conexión y otras contribuciones de UI sin inyectar código en la ventana principal de DBX.",
noInstalledPlugins: "No hay plugins instalados",
noInstalledPluginsDescription: "Instala un plugin de backend para previsualizar aquí sus contribuciones de frontend.",
pluginHostPreview: "Vista previa de la API del host",
pluginNoFrontendContribution: "Este plugin aún no declara ninguna contribución de frontend.",
storageTab: "Almacenamiento",
runtimeTitle: "Runtime de drivers",
runtimeHint: "Las métricas de runtime se recopilan solo mientras este panel está abierto.",
+110
View File
@@ -165,6 +165,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "Apri con {label}",
centerTitle: "Centro plugin",
centerDescription: "Scopri funzionalità DBX opzionali in un marketplace unico e gestisci separatamente plugin installati e repository.",
marketplace: "Marketplace",
installed: "Installati",
settings: "Impostazioni",
searchMarketplace: "Cerca plugin, autori o tag",
allRepositories: "Tutti i repository",
loadingMarketplace: "Caricamento marketplace plugin",
noMarketplacePlugins: "Nessun plugin da mostrare",
noMarketplacePluginsDescription: "Configura un repository nelle Impostazioni oppure modifica ricerca e filtri.",
verified: "Verificato",
noDescription: "Nessuna descrizione.",
permissionsCount: "{count} autorizzazioni",
unsupportedTarget: "Non disponibile per {target}",
installedVersion: "Installato v{version}",
licenseUnknown: "Licenza non specificata",
sourceRepository: "Repository del codice sorgente",
pluginHomepage: "Sito web",
marketplaceStatus: { install: "Installa", installed: "Installato", update: "Aggiorna", unsupported: "Non supportato" },
updateSuccess: "{name} aggiornato alla versione {version}",
noInstalledPlugins: "Nessun plugin installato",
browseMarketplace: "Sfoglia Marketplace",
localInstallTitle: "Installa un pacchetto locale",
localInstallDescription: "Installa un .dbxp firmato ottenuto fuori dal marketplace. DBX verifica automaticamente integrità e firma del repository.",
repositoriesTitle: "Repository dei plugin",
repositoriesDescription: "La sorgente ufficiale è gestita da DBX. I repository self-hosted o di terze parti usano le impostazioni di attendibilità del repository.",
repositoryFieldsRequired: "ID, nome e URL del catalogo sono obbligatori",
repositorySaved: "Repository plugin '{name}' salvato",
removeRepositoryConfirm: "Rimuovere il repository plugin '{name}'? I plugin installati resteranno disponibili.",
repositoryRemoved: "Repository plugin '{name}' rimosso",
repositoryKind: { official: "Ufficiale", custom: "Personalizzato", enterprise: "Enterprise" },
repositoryNotConfigured: "URL del catalogo non configurato in questa build",
enabled: "Abilitato",
disabled: "Disabilitato",
enable: "Abilita",
disable: "Disabilita",
repositoryIdPlaceholder: "ID repository",
repositoryNamePlaceholder: "Nome repository",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "Aggiungi repository",
signedPackagesVerifiedAutomatically: "Integrità e firme verificate automaticamente",
developerOptionsTitle: "Opzioni di terze parti e sviluppo",
developerOptionsDescription: "Usale solo per repository personalizzati o sviluppo locale di plugin.",
developerOptionsWarning: "I plugin non firmati possono eseguire codice nativo con i permessi dell'utente corrente. Abilitali solo per pacchetti creati e verificati da te.",
connectionManagedInDialog: "Crea e modifica le connessioni nella finestra unificata Nuova connessione. Questa pagina gestisce solo i plugin e ne mostra le capacità.",
allowUnsignedDevelopmentPackage: "Consenti pacchetti di sviluppo non firmati",
allowUnsignedDevelopmentPackageDescription: "Si applica solo all'installazione manuale di .dbxp e non riduce mai la verifica del marketplace ufficiale.",
installPackage: "Installa .dbxp",
dropToInstall: "Rilascia per installare il pacchetto del plugin",
dropInstallHint: "Puoi anche rilasciare un file .dbxp su questa pagina",
packageFileType: "Pacchetto plugin DBX",
repositoryTrustTitle: "Attendibilità del repository personalizzato",
repositoryTrustDescription: "Il gestore del repository firma tutti i pacchetti approvati con una singola chiave. Ottieni la chiave pubblica tramite un canale separato e attendibile.",
repositoryKeyAdded: "Chiave di firma del repository '{keyId}' aggiunta",
removeRepositoryKeyConfirm: "Rimuovere la chiave di firma del repository '{keyId}'? I plugin installati resteranno installati.",
repositoryKeyRemoved: "Chiave di firma del repository '{keyId}' rimossa",
repositoryKeyIdPlaceholder: "ID chiave repository",
repositoryPublicKeyPlaceholder: "Chiave pubblica Ed25519 Base64 del repository (32 byte)",
trustRepository: "Considera attendibile il repository",
noInstalledPluginsDescription: "Installa un .dbxp firmato oppure abilita esplicitamente i pacchetti non firmati per lo sviluppo locale.",
compatible: "Compatibile",
blocked: "Bloccato",
rollback: "Ripristina",
uninstall: "Disinstalla",
rollbackConfirm: "Ripristinare questo plugin alla versione installata precedente?",
rollbackSuccess: "Ripristinata la versione {version}",
uninstallConfirm: "Disinstallare {name}?",
uninstallSuccess: "{name} disinstallato",
installSuccess: "Installato {name} {version}",
selectConnectionProvider: "Seleziona prima un provider di connessione",
connectionProvider: "Provider di connessione",
newConnection: "Nuova",
workbenches: "Aree di lavoro",
filesystemProviders: "Provider del file system",
open: "Apri",
browse: "Sfoglia",
parentDirectory: "Directory superiore",
filesystemUri: "URI del file system",
refresh: "Aggiorna",
loadingFiles: "Caricamento file",
emptyDirectory: "Questa directory è vuota.",
loadMore: "Carica altro",
fileManagerHint: "Fai doppio clic su una directory per aprirla o su un file per visualizzare fino a 256 KB.",
loadingPreview: "Caricamento anteprima",
previewTruncated: "Anteprima troncata a 256 KB",
previewSelectedFileHint: "Fai doppio clic sul file selezionato per caricarne l'anteprima.",
entryCannotPreview: "Questo elemento non può essere visualizzato in anteprima.",
providerNoReadCapability: "Il provider non dichiara la capacità di lettura.",
filesystemUnavailable: "Il file system del plugin '{pluginId}/{providerId}' non è disponibile",
loadingFilesystem: "Caricamento file system del plugin",
filesystemUnavailableFallback: "Il file system del plugin non è disponibile",
workbenchUnavailable: "L'area di lavoro del plugin '{pluginId}/{contributionId}' non è disponibile",
loadingWorkbench: "Caricamento area di lavoro del plugin",
workbenchUnavailableFallback: "L'area di lavoro del plugin non è disponibile",
pluginIncompatible: "Il plugin non è compatibile",
loadingTitle: "Caricamento di {title}",
},
auth: {
rateLimited: "Riprova tra {seconds} s",
setupTitle: "Imposta la password di accesso",
@@ -230,6 +329,7 @@ export default withEnglishFallback({
sqlOpenFailed: "Impossibile aprire il file: {message}",
sqlSaveFailed: "Impossibile salvare il file: {message}",
driverManager: "Gestione Driver",
pluginCenter: "Centro plugin",
updatableDriverCount: "Driver aggiornabili",
mcpUpdateAvailable: "Aggiornamento server MCP disponibile",
blockDangerousRedisCommands: "Blocca comandi pericolosi",
@@ -814,6 +914,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "Temporali",
databaseCategoryMq: "Code messaggi",
databaseCategoryRegistryConfig: "Registro/config.",
databaseCategoryPlugins: "Plugin",
pluginProviderUnavailable: "Questo provider di connessione del plugin non è disponibile. Reinstalla o abilita il plugin e riprova.",
pluginRequiredField: "{field} è obbligatorio",
jdbcConnection: "Connessione JDBC",
iconView: "Vista icone",
listView: "Vista elenco",
@@ -7519,6 +7622,13 @@ export default withEnglishFallback({
localInstallHint:
"Per ambienti completamente isolati: su un altro computer con accesso a internet, scarica il pacchetto ufficiale dbx-jdbc-plugin-*.zip dalla pagina Releases di DBX su GitHub, trasferiscilo su questa macchina e selezionalo: non è richiesto alcun accesso alla rete su questa macchina.",
runtimeDrivers: "Runtime",
pluginsTab: "Plugin",
pluginContributionsTitle: "Contributi frontend dei plugin",
pluginContributionsDescription: "I plugin installati possono descrivere moduli di connessione e altri contributi UI senza iniettare codice nella finestra principale di DBX.",
noInstalledPlugins: "Nessun plugin installato",
noInstalledPluginsDescription: "Installa un plugin backend per visualizzare qui l'anteprima dei suoi contributi frontend.",
pluginHostPreview: "Anteprima API host",
pluginNoFrontendContribution: "Questo plugin non dichiara ancora alcun contributo frontend.",
storageTab: "Archiviazione",
runtimeTitle: "Runtime del driver",
runtimeHint: "Le metriche di runtime vengono raccolte solo mentre questo pannello è aperto.",
+110
View File
@@ -166,6 +166,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "{label} で開く",
centerTitle: "プラグインセンター",
centerDescription: "統合マーケットプレイスで DBX の追加機能を探し、インストール済みプラグインとリポジトリを個別に管理します。",
marketplace: "マーケットプレイス",
installed: "インストール済み",
settings: "設定",
searchMarketplace: "プラグイン、発行者、タグを検索",
allRepositories: "すべてのリポジトリ",
loadingMarketplace: "プラグインマーケットプレイスを読み込み中",
noMarketplacePlugins: "表示できるプラグインがありません",
noMarketplacePluginsDescription: "設定でリポジトリを追加するか、検索条件を変更してください。",
verified: "認証済み",
noDescription: "説明はありません。",
permissionsCount: "権限 {count} 件",
unsupportedTarget: "{target} では利用できません",
installedVersion: "インストール済み v{version}",
licenseUnknown: "ライセンス未指定",
sourceRepository: "ソースリポジトリ",
pluginHomepage: "ホームページ",
marketplaceStatus: { install: "インストール", installed: "インストール済み", update: "更新", unsupported: "非対応" },
updateSuccess: "{name} を {version} に更新しました",
noInstalledPlugins: "プラグインはまだありません",
browseMarketplace: "マーケットプレイスを見る",
localInstallTitle: "ローカルパッケージをインストール",
localInstallDescription: "マーケットプレイス外で入手した署名済み .dbxp をインストールします。DBX が整合性とリポジトリ署名を自動検証します。",
repositoriesTitle: "プラグインリポジトリ",
repositoriesDescription: "公式ソースは DBX が管理します。セルフホストまたは第三者のリポジトリでは、リポジトリの信頼設定を使用します。",
repositoryFieldsRequired: "リポジトリ ID、名前、カタログ URL は必須です",
repositorySaved: "プラグインリポジトリ「{name}」を保存しました",
removeRepositoryConfirm: "プラグインリポジトリ「{name}」を削除しますか?インストール済みプラグインは残ります。",
repositoryRemoved: "プラグインリポジトリ「{name}」を削除しました",
repositoryKind: { official: "公式", custom: "カスタム", enterprise: "エンタープライズ" },
repositoryNotConfigured: "このビルドにはカタログ URL が設定されていません",
enabled: "有効",
disabled: "無効",
enable: "有効化",
disable: "無効化",
repositoryIdPlaceholder: "リポジトリ ID",
repositoryNamePlaceholder: "リポジトリ名",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "リポジトリを追加",
signedPackagesVerifiedAutomatically: "整合性と署名を自動検証",
developerOptionsTitle: "サードパーティと開発者向けオプション",
developerOptionsDescription: "カスタムリポジトリまたはローカルプラグイン開発時のみ使用してください。",
developerOptionsWarning: "未署名プラグインは現在のユーザー権限でネイティブコードを実行できます。自分でビルドし信頼できる開発パッケージにのみ有効化してください。",
connectionManagedInDialog: "接続の作成と編集は共通の「新しい接続」ダイアログで行います。この画面はプラグイン管理と機能表示専用です。",
allowUnsignedDevelopmentPackage: "未署名の開発パッケージを許可",
allowUnsignedDevelopmentPackageDescription: "手動での .dbxp インストールにのみ適用され、公式マーケットプレイスの検証は緩和されません。",
installPackage: ".dbxp をインストール",
dropToInstall: "ドロップしてプラグインをインストール",
dropInstallHint: ".dbxp ファイルをこのページにドロップしてもインストールできます",
packageFileType: "DBX プラグインパッケージ",
repositoryTrustTitle: "カスタムリポジトリの信頼",
repositoryTrustDescription: "カスタムリポジトリ運営者は、承認したすべてのパッケージを単一のリポジトリ鍵で署名します。公開鍵は別の信頼できる経路で取得してください。",
repositoryKeyAdded: "リポジトリ署名鍵「{keyId}」を追加しました",
removeRepositoryKeyConfirm: "リポジトリ署名鍵「{keyId}」を削除しますか?インストール済みプラグインは残ります。",
repositoryKeyRemoved: "リポジトリ署名鍵「{keyId}」を削除しました",
repositoryKeyIdPlaceholder: "リポジトリ鍵 ID",
repositoryPublicKeyPlaceholder: "Base64 Ed25519 リポジトリ公開鍵(32 バイト)",
trustRepository: "リポジトリを信頼",
noInstalledPluginsDescription: "署名済み .dbxp をインストールするか、ローカル開発時のみ未署名パッケージを明示的に許可してください。",
compatible: "互換",
blocked: "ブロック済み",
rollback: "ロールバック",
uninstall: "アンインストール",
rollbackConfirm: "このプラグインを前のインストール済みバージョンへ戻しますか?",
rollbackSuccess: "{version} にロールバックしました",
uninstallConfirm: "{name} をアンインストールしますか?",
uninstallSuccess: "{name} をアンインストールしました",
installSuccess: "{name} {version} をインストールしました",
selectConnectionProvider: "先に接続プロバイダーを選択してください",
connectionProvider: "接続プロバイダー",
newConnection: "新規",
workbenches: "ワークベンチ",
filesystemProviders: "ファイルシステムプロバイダー",
open: "開く",
browse: "参照",
parentDirectory: "親ディレクトリ",
filesystemUri: "ファイルシステム URI",
refresh: "更新",
loadingFiles: "ファイルを読み込み中",
emptyDirectory: "このディレクトリは空です。",
loadMore: "さらに読み込む",
fileManagerHint: "ディレクトリをダブルクリックして開くか、ファイルをダブルクリックして最大 256 KB までプレビューします。",
loadingPreview: "プレビューを読み込み中",
previewTruncated: "プレビューは 256 KB で切り捨てられました",
previewSelectedFileHint: "選択したファイルをダブルクリックしてプレビューを読み込みます。",
entryCannotPreview: "この項目はプレビューできません。",
providerNoReadCapability: "プロバイダーは読み取り機能を宣言していません。",
filesystemUnavailable: "プラグインファイルシステム「{pluginId}/{providerId}」は利用できません",
loadingFilesystem: "プラグインファイルシステムを読み込み中",
filesystemUnavailableFallback: "プラグインファイルシステムは利用できません",
workbenchUnavailable: "プラグインワークベンチ「{pluginId}/{contributionId}」は利用できません",
loadingWorkbench: "プラグインワークベンチを読み込み中",
workbenchUnavailableFallback: "プラグインワークベンチは利用できません",
pluginIncompatible: "プラグインに互換性がありません",
loadingTitle: "{title} を読み込み中",
},
auth: {
rateLimited: "{seconds} 秒後に再試行してください",
setupTitle: "アクセスパスワードを設定",
@@ -231,6 +330,7 @@ export default withEnglishFallback({
sqlOpenFailed: "ファイルを開けませんでした: {message}",
sqlSaveFailed: "ファイルの保存に失敗しました: {message}",
driverManager: "ドライバーマネージャー",
pluginCenter: "プラグインセンター",
updatableDriverCount: "更新可能なドライバー数",
mcpUpdateAvailable: "MCPサーバーの更新があります",
blockDangerousRedisCommands: "危険なコマンドをブロック",
@@ -780,6 +880,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "時系列DB",
databaseCategoryMq: "メッセージキュー",
databaseCategoryRegistryConfig: "登録/設定",
databaseCategoryPlugins: "プラグイン",
pluginProviderUnavailable: "このプラグイン接続プロバイダーは利用できません。プラグインを再インストールまたは有効化してください。",
pluginRequiredField: "{field} は必須です",
jdbcConnection: "JDBC 接続",
iconView: "アイコン表示",
listView: "リスト表示",
@@ -7570,6 +7673,13 @@ export default withEnglishFallback({
localInstall: "ローカルインストール",
localInstallHint: "完全に隔離されたネットワーク環境の場合:インターネットに接続できる別のコンピューターで DBX の GitHub Releases ページから公式の dbx-jdbc-plugin-*.zip をダウンロードし、このマシンに転送して選択してください。このマシンはネットワークに接続する必要はありません。",
runtimeDrivers: "ランタイム",
pluginsTab: "プラグイン",
pluginContributionsTitle: "フロントエンドプラグインコントリビューション",
pluginContributionsDescription: "インストール済みプラグインは、DBX のメインウィンドウにコードを注入せず、接続フォームやその他の UI コントリビューションを記述できます。",
noInstalledPlugins: "インストール済みプラグインはありません",
noInstalledPluginsDescription: "バックエンドプラグインをインストールすると、ここでフロントエンドコントリビューションをプレビューできます。",
pluginHostPreview: "ホスト API プレビュー",
pluginNoFrontendContribution: "このプラグインはまだフロントエンドコントリビューションを宣言していません。",
storageTab: "ストレージ",
runtimeTitle: "ドライバーランタイム",
runtimeHint: "ランタイムメトリクスはこのパネルが開いている間のみ収集されます。",
+119
View File
@@ -165,6 +165,114 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "{label}(으)로 열기",
centerTitle: "플러그인 센터",
centerDescription: "하나의 마켓플레이스에서 선택적 DBX 기능을 찾고, 설치된 플러그인과 저장소를 별도로 관리하세요.",
marketplace: "마켓플레이스",
installed: "설치됨",
settings: "설정",
searchMarketplace: "플러그인, 게시자 또는 태그 검색",
allRepositories: "모든 저장소",
loadingMarketplace: "플러그인 마켓플레이스 로드 중",
noMarketplacePlugins: "표시할 플러그인이 없습니다",
noMarketplacePluginsDescription: "설정에서 저장소를 구성하거나 현재 검색 및 필터를 조정하세요.",
verified: "검증됨",
noDescription: "설명이 제공되지 않았습니다.",
permissionsCount: "권한 {count}개",
unsupportedTarget: "{target}에서는 사용할 수 없음",
installedVersion: "설치된 버전 v{version}",
licenseUnknown: "라이선스 미지정",
sourceRepository: "소스 저장소",
pluginHomepage: "홈페이지",
marketplaceStatus: {
install: "설치",
installed: "설치됨",
update: "업데이트",
unsupported: "지원되지 않음",
},
updateSuccess: "{name} 업데이트 완료: {version}",
noInstalledPlugins: "설치된 플러그인이 없습니다",
browseMarketplace: "마켓플레이스 둘러보기",
localInstallTitle: "로컬 패키지 설치",
localInstallDescription: "마켓플레이스 외부에서 받은 서명된 .dbxp를 설치합니다. DBX가 패키지 무결성과 저장소 서명을 자동으로 검증합니다.",
repositoriesTitle: "플러그인 저장소",
repositoriesDescription: "공식 소스는 DBX에서 관리합니다. 자체 호스팅 또는 타사 저장소는 저장소 신뢰 설정을 사용합니다.",
repositoryFieldsRequired: "저장소 ID, 이름 및 카탈로그 URL은 필수입니다",
repositorySaved: "플러그인 저장소 '{name}'을 저장했습니다",
removeRepositoryConfirm: "플러그인 저장소 '{name}'을 제거하시겠습니까? 설치된 플러그인은 유지됩니다.",
repositoryRemoved: "플러그인 저장소 '{name}'을 제거했습니다",
repositoryKind: {
official: "공식",
custom: "사용자 지정",
enterprise: "엔터프라이즈",
},
repositoryNotConfigured: "이 빌드에는 카탈로그 URL이 구성되어 있지 않습니다",
enabled: "활성화됨",
disabled: "비활성화됨",
enable: "활성화",
disable: "비활성화",
repositoryIdPlaceholder: "저장소 ID",
repositoryNamePlaceholder: "저장소 이름",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "저장소 추가",
signedPackagesVerifiedAutomatically: "서명이 자동으로 검증됩니다",
developerOptionsTitle: "타사 및 개발자 옵션",
developerOptionsDescription: "사용자 지정 저장소 또는 로컬 플러그인 개발에만 사용하세요.",
developerOptionsWarning: "서명되지 않은 플러그인은 사용자 권한으로 네이티브 코드를 실행할 수 있습니다. 직접 빌드하고 신뢰하는 패키지에만 활성화하세요.",
connectionManagedInDialog: "통합된 새 연결 대화상자에서 연결을 만들고 편집하세요. 이 페이지는 플러그인 관리와 기능 표시에만 사용됩니다.",
allowUnsignedDevelopmentPackage: "서명되지 않은 개발 패키지 허용",
allowUnsignedDevelopmentPackageDescription: "수동 .dbxp 설치에만 적용되며 공식 마켓플레이스 검증은 완화하지 않습니다.",
installPackage: ".dbxp 설치",
dropToInstall: "놓아서 플러그인 패키지 설치",
dropInstallHint: ".dbxp 파일을 이 페이지에 끌어다 놓아도 설치할 수 있습니다",
packageFileType: "DBX 플러그인 패키지",
repositoryTrustTitle: "사용자 지정 저장소 신뢰",
repositoryTrustDescription: "사용자 지정 저장소 운영자는 하나의 저장소 키로 승인된 모든 패키지에 서명합니다. 별도의 신뢰할 수 있는 채널을 통해 공개 키를 받으세요.",
repositoryKeyAdded: "저장소 서명 키 '{keyId}'을 추가했습니다",
removeRepositoryKeyConfirm: "저장소 서명 키 '{keyId}'을 제거하시겠습니까? 설치된 플러그인은 유지됩니다.",
repositoryKeyRemoved: "저장소 서명 키 '{keyId}'을 제거했습니다",
repositoryKeyIdPlaceholder: "저장소 키 ID",
repositoryPublicKeyPlaceholder: "Base64 Ed25519 저장소 공개 키(32바이트)",
trustRepository: "저장소 신뢰",
noInstalledPluginsDescription: "서명된 .dbxp를 설치하거나 로컬 개발용 서명되지 않은 패키지를 명시적으로 허용하세요.",
compatible: "호환됨",
blocked: "차단됨",
rollback: "롤백",
uninstall: "제거",
rollbackConfirm: "이 플러그인을 이전 설치 버전으로 되돌리시겠습니까?",
rollbackSuccess: "{version}(으)로 롤백했습니다",
uninstallConfirm: "{name}을 제거하시겠습니까?",
uninstallSuccess: "{name}을 제거했습니다",
installSuccess: "{name} {version}을 설치했습니다",
selectConnectionProvider: "먼저 연결 제공자를 선택하세요",
connectionProvider: "연결 제공자",
newConnection: "새 연결",
workbenches: "워크벤치",
filesystemProviders: "파일 시스템 제공자",
open: "열기",
browse: "찾아보기",
parentDirectory: "상위 디렉터리",
filesystemUri: "파일 시스템 URI",
refresh: "새로 고침",
loadingFiles: "파일 로드 중",
emptyDirectory: "이 디렉터리는 비어 있습니다.",
loadMore: "더 불러오기",
fileManagerHint: "디렉터리를 두 번 클릭해 열거나 파일을 두 번 클릭해 최대 256KB까지 미리 봅니다.",
loadingPreview: "미리보기 로드 중",
previewTruncated: "미리보기가 256KB에서 잘렸습니다",
previewSelectedFileHint: "선택한 파일을 두 번 클릭해 미리보기를 로드하세요.",
entryCannotPreview: "이 항목은 미리 볼 수 없습니다.",
providerNoReadCapability: "제공자가 읽기 기능을 선언하지 않았습니다.",
filesystemUnavailable: "플러그인 파일 시스템 '{pluginId}/{providerId}'을 사용할 수 없습니다",
loadingFilesystem: "플러그인 파일 시스템 로드 중",
filesystemUnavailableFallback: "플러그인 파일 시스템을 사용할 수 없습니다",
workbenchUnavailable: "플러그인 워크벤치 '{pluginId}/{contributionId}'을 사용할 수 없습니다",
loadingWorkbench: "플러그인 워크벤치 로드 중",
workbenchUnavailableFallback: "플러그인 워크벤치를 사용할 수 없습니다",
pluginIncompatible: "플러그인이 호환되지 않습니다",
loadingTitle: "{title} 로드 중",
},
auth: {
rateLimited: "{seconds}초 후에 다시 시도해 주세요",
setupTitle: "접속 비밀번호 설정",
@@ -233,6 +341,7 @@ export default withEnglishFallback({
mcpUpdateAvailable: "MCP 서버 업데이트 가능",
blockDangerousRedisCommands: "위험한 명령 차단",
saveSqlFile: "원본 파일에 저장",
pluginCenter: "플러그인 센터",
},
multiDbExecute: {
title: "다중 데이터베이스 실행",
@@ -1191,6 +1300,9 @@ export default withEnglishFallback({
sshTotpCancelled: "일회용 코드 입력이 취소되었습니다",
mqttSearchPayloadPlaceholder: "메시지 Payload 검색...",
mqttNoMatchingMessages: "현재 Payload 필터 조건과 일치하는 로드된 메시지가 없습니다.",
databaseCategoryPlugins: "플러그인",
pluginProviderUnavailable: "이 플러그인 연결 제공자를 사용할 수 없습니다. 플러그인을 다시 설치하거나 활성화한 후 다시 시도하세요.",
pluginRequiredField: "{field}을(를) 입력하세요.",
},
editor: {
duckdbDraining: "이전 DuckDB 쿼리가 아직 중지 중입니다. 잠시 후 다시 시도해 주세요.",
@@ -7242,6 +7354,13 @@ export default withEnglishFallback({
localInstall: "로컬 설치",
localInstallHint: "완전한 폐쇄망 환경의 경우: 인터넷에 연결된 다른 컴퓨터에서 DBX GitHub Releases 페이지의 공식 dbx-jdbc-plugin-*.zip을 다운로드한 뒤 이 기기로 옮겨 선택하세요. 이 기기는 네트워크에 연결할 필요가 없습니다.",
runtimeDrivers: "런타임",
pluginsTab: "플러그인",
pluginContributionsTitle: "프론트엔드 플러그인 확장",
pluginContributionsDescription: "설치된 플러그인은 DBX 기본 창에 코드를 주입하지 않고 연결 양식과 기타 UI 확장을 선언할 수 있습니다.",
noInstalledPlugins: "설치된 플러그인이 없습니다",
noInstalledPluginsDescription: "백엔드 플러그인을 설치하면 여기에서 프론트엔드 확장을 미리 볼 수 있습니다.",
pluginHostPreview: "호스트 API 미리보기",
pluginNoFrontendContribution: "이 플러그인은 아직 프론트엔드 확장을 선언하지 않았습니다.",
storageTab: "저장 공간",
runtimeTitle: "드라이버 런타임",
runtimeHint: "런타임 메트릭은 이 패널이 열려 있는 동안에만 수집됩니다.",
+110
View File
@@ -166,6 +166,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "Abrir com {label}",
centerTitle: "Central de plugins",
centerDescription: "Descubra recursos opcionais do DBX em um marketplace unificado e gerencie separadamente plugins instalados e repositórios.",
marketplace: "Marketplace",
installed: "Instalados",
settings: "Configurações",
searchMarketplace: "Buscar plugins, publicadores ou tags",
allRepositories: "Todos os repositórios",
loadingMarketplace: "Carregando marketplace de plugins",
noMarketplacePlugins: "Nenhum plugin para exibir",
noMarketplacePluginsDescription: "Configure um repositório nas Configurações ou ajuste a busca e os filtros.",
verified: "Verificado",
noDescription: "Nenhuma descrição fornecida.",
permissionsCount: "{count} permissões",
unsupportedTarget: "Indisponível para {target}",
installedVersion: "Instalado v{version}",
licenseUnknown: "Licença não informada",
sourceRepository: "Repositório do código-fonte",
pluginHomepage: "Página inicial",
marketplaceStatus: { install: "Instalar", installed: "Instalado", update: "Atualizar", unsupported: "Não compatível" },
updateSuccess: "{name} atualizado para {version}",
noInstalledPlugins: "Nenhum plugin instalado",
browseMarketplace: "Explorar Marketplace",
localInstallTitle: "Instalar pacote local",
localInstallDescription: "Instale um .dbxp assinado obtido fora do marketplace. O DBX verifica automaticamente a integridade e a assinatura do repositório.",
repositoriesTitle: "Repositórios de plugins",
repositoriesDescription: "A fonte oficial é gerenciada pelo DBX. Repositórios próprios ou de terceiros usam as configurações de confiança do repositório.",
repositoryFieldsRequired: "ID, nome e URL do catálogo são obrigatórios",
repositorySaved: "Repositório de plugins '{name}' salvo",
removeRepositoryConfirm: "Remover o repositório de plugins '{name}'? Os plugins instalados permanecerão.",
repositoryRemoved: "Repositório de plugins '{name}' removido",
repositoryKind: { official: "Oficial", custom: "Personalizado", enterprise: "Empresarial" },
repositoryNotConfigured: "A URL do catálogo não está configurada nesta compilação",
enabled: "Ativado",
disabled: "Desativado",
enable: "Ativar",
disable: "Desativar",
repositoryIdPlaceholder: "ID do repositório",
repositoryNamePlaceholder: "Nome do repositório",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "Adicionar repositório",
signedPackagesVerifiedAutomatically: "Integridade e assinaturas verificadas automaticamente",
developerOptionsTitle: "Opções de terceiros e desenvolvimento",
developerOptionsDescription: "Use somente para repositórios personalizados ou desenvolvimento local de plugins.",
developerOptionsWarning: "Plugins sem assinatura podem executar código nativo com suas permissões de usuário. Ative apenas para pacotes criados e verificados por você.",
connectionManagedInDialog: "Crie e edite conexões na janela unificada Nova conexão. Esta página apenas gerencia plugins e exibe seus recursos.",
allowUnsignedDevelopmentPackage: "Permitir pacotes de desenvolvimento não assinados",
allowUnsignedDevelopmentPackageDescription: "Afeta apenas a instalação manual de .dbxp e nunca reduz a verificação do marketplace oficial.",
installPackage: "Instalar .dbxp",
dropToInstall: "Solte para instalar o pacote do plugin",
dropInstallHint: "Você também pode soltar um arquivo .dbxp nesta página",
packageFileType: "Pacote de plugin do DBX",
repositoryTrustTitle: "Confiança do repositório personalizado",
repositoryTrustDescription: "O operador do repositório assina todos os pacotes aprovados com uma única chave. Obtenha a chave pública por um canal separado e confiável.",
repositoryKeyAdded: "Chave de assinatura do repositório '{keyId}' adicionada",
removeRepositoryKeyConfirm: "Remover a chave de assinatura do repositório '{keyId}'? Os plugins instalados permanecerão instalados.",
repositoryKeyRemoved: "Chave de assinatura do repositório '{keyId}' removida",
repositoryKeyIdPlaceholder: "ID da chave do repositório",
repositoryPublicKeyPlaceholder: "Chave pública Ed25519 do repositório em Base64 (32 bytes)",
trustRepository: "Confiar no repositório",
noInstalledPluginsDescription: "Instale um .dbxp assinado ou permita explicitamente pacotes não assinados para desenvolvimento local.",
compatible: "Compatível",
blocked: "Bloqueado",
rollback: "Reverter",
uninstall: "Desinstalar",
rollbackConfirm: "Reverter este plugin para a versão instalada anterior?",
rollbackSuccess: "Revertido para {version}",
uninstallConfirm: "Desinstalar {name}?",
uninstallSuccess: "{name} desinstalado",
installSuccess: "{name} {version} instalado",
selectConnectionProvider: "Selecione primeiro um provedor de conexão",
connectionProvider: "Provedor de conexão",
newConnection: "Nova",
workbenches: "Áreas de trabalho",
filesystemProviders: "Provedores de sistema de arquivos",
open: "Abrir",
browse: "Explorar",
parentDirectory: "Diretório superior",
filesystemUri: "URI do sistema de arquivos",
refresh: "Atualizar",
loadingFiles: "Carregando arquivos",
emptyDirectory: "Este diretório está vazio.",
loadMore: "Carregar mais",
fileManagerHint: "Clique duas vezes em um diretório para abri-lo ou em um arquivo para visualizar até 256 KB.",
loadingPreview: "Carregando visualização",
previewTruncated: "Visualização truncada em 256 KB",
previewSelectedFileHint: "Clique duas vezes no arquivo selecionado para carregar sua visualização.",
entryCannotPreview: "Este item não pode ser visualizado.",
providerNoReadCapability: "O provedor não declara capacidade de leitura.",
filesystemUnavailable: "O sistema de arquivos do plugin '{pluginId}/{providerId}' não está disponível",
loadingFilesystem: "Carregando sistema de arquivos do plugin",
filesystemUnavailableFallback: "O sistema de arquivos do plugin não está disponível",
workbenchUnavailable: "A área de trabalho do plugin '{pluginId}/{contributionId}' não está disponível",
loadingWorkbench: "Carregando área de trabalho do plugin",
workbenchUnavailableFallback: "A área de trabalho do plugin não está disponível",
pluginIncompatible: "O plugin não é compatível",
loadingTitle: "Carregando {title}",
},
auth: {
rateLimited: "Tente novamente em {seconds}s",
setupTitle: "Configurar senha de acesso",
@@ -231,6 +330,7 @@ export default withEnglishFallback({
sqlOpenFailed: "Falha ao abrir o arquivo: {message}",
sqlSaveFailed: "Falha ao salvar o arquivo: {message}",
driverManager: "Gerenciador de Drivers",
pluginCenter: "Central de plugins",
updatableDriverCount: "Quantidade de drivers atualizáveis",
mcpUpdateAvailable: "Atualização do servidor MCP disponível",
blockDangerousRedisCommands: "Bloquear comandos perigosos",
@@ -815,6 +915,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "Séries",
databaseCategoryMq: "Filas",
databaseCategoryRegistryConfig: "Registro/config.",
databaseCategoryPlugins: "Plugins",
pluginProviderUnavailable: "Este provedor de conexão do plugin não está disponível. Reinstale ou habilite o plugin e tente novamente.",
pluginRequiredField: "{field} é obrigatório",
jdbcConnection: "Conexão JDBC",
iconView: "Visualização em ícones",
listView: "Visualização em lista",
@@ -7520,6 +7623,13 @@ export default withEnglishFallback({
localInstall: "Instalação local",
localInstallHint: "Para ambientes sem acesso à internet: em outro computador com internet, baixe o dbx-jdbc-plugin-*.zip oficial na página de Releases do DBX no GitHub, transfira-o para esta máquina e selecione-o — nenhum acesso à rede é necessário aqui.",
runtimeDrivers: "Runtime",
pluginsTab: "Plugins",
pluginContributionsTitle: "Contribuições de frontend de plugins",
pluginContributionsDescription: "Plugins instalados podem descrever formulários de conexão e outras contribuições de UI sem injetar código na janela principal do DBX.",
noInstalledPlugins: "Nenhum plugin instalado",
noInstalledPluginsDescription: "Instale um plugin de backend para visualizar aqui suas contribuições de frontend.",
pluginHostPreview: "Prévia da API do host",
pluginNoFrontendContribution: "Este plugin ainda não declara uma contribuição de frontend.",
storageTab: "Armazenamento",
runtimeTitle: "Runtime do driver",
runtimeHint: "As métricas de runtime são coletadas apenas enquanto este painel está aberto.",
+110
View File
@@ -89,6 +89,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "用 {label} 打开",
centerTitle: "插件中心",
centerDescription: "在统一商店中发现可选的 DBX 能力,并分别管理已安装插件和插件仓库。",
marketplace: "插件商店",
installed: "已安装",
settings: "设置",
searchMarketplace: "搜索插件、发布者或标签",
allRepositories: "全部仓库",
loadingMarketplace: "正在加载插件商店",
noMarketplacePlugins: "暂无可展示的插件",
noMarketplacePluginsDescription: "请在设置中配置插件仓库,或调整当前搜索与筛选条件。",
verified: "已认证",
noDescription: "开发者未提供说明。",
permissionsCount: "{count} 项权限",
unsupportedTarget: "暂不支持 {target}",
installedVersion: "已安装 v{version}",
licenseUnknown: "未声明许可证",
sourceRepository: "源码仓库",
pluginHomepage: "主页",
marketplaceStatus: { install: "安装", installed: "已安装", update: "更新", unsupported: "不支持" },
updateSuccess: "已将 {name} 更新到 {version}",
noInstalledPlugins: "尚未安装插件",
browseMarketplace: "浏览插件商店",
localInstallTitle: "安装本地插件包",
localInstallDescription: "安装从插件商店之外获得的已签名 .dbxp,DBX 会自动校验包完整性和仓库签名。",
repositoriesTitle: "插件仓库",
repositoriesDescription: "官方源由 DBX 管理;添加自托管或第三方仓库后,才会显示对应的仓库信任设置。",
repositoryFieldsRequired: "仓库 ID、名称和目录地址不能为空",
repositorySaved: "已保存插件仓库“{name}”",
removeRepositoryConfirm: "移除插件仓库“{name}”?已安装插件不会被卸载。",
repositoryRemoved: "已移除插件仓库“{name}”",
repositoryKind: { official: "官方", custom: "自定义", enterprise: "企业" },
repositoryNotConfigured: "当前构建未配置目录地址",
enabled: "已启用",
disabled: "已停用",
enable: "启用",
disable: "停用",
repositoryIdPlaceholder: "仓库 ID",
repositoryNamePlaceholder: "仓库名称",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "添加仓库",
signedPackagesVerifiedAutomatically: "自动校验完整性和签名",
developerOptionsTitle: "第三方与开发者选项",
developerOptionsDescription: "在接入第三方仓库或开发本地插件时使用。",
developerOptionsWarning: "未签名插件可以使用当前用户权限运行原生代码。只应为自己构建且确认可信的开发包开启。",
connectionManagedInDialog: "连接统一在“新建连接”对话框中创建和编辑;这里仅负责插件管理与能力展示。",
allowUnsignedDevelopmentPackage: "允许安装未签名开发包",
allowUnsignedDevelopmentPackageDescription: "仅影响手动安装的 .dbxp,不会放宽官方插件商店的签名校验。",
installPackage: "安装 .dbxp",
dropToInstall: "释放以安装插件包",
dropInstallHint: "也可拖放 .dbxp 到插件中心页面安装",
packageFileType: "DBX 插件包",
repositoryTrustTitle: "自定义仓库信任",
repositoryTrustDescription: "自定义仓库运营方使用一个仓库密钥签署其审核通过的插件。请通过独立可信渠道获取仓库公钥。",
repositoryKeyAdded: "已添加仓库签名密钥“{keyId}”",
removeRepositoryKeyConfirm: "移除仓库签名密钥“{keyId}”?已安装的插件不会被卸载。",
repositoryKeyRemoved: "已移除仓库签名密钥“{keyId}”",
repositoryKeyIdPlaceholder: "仓库密钥 ID",
repositoryPublicKeyPlaceholder: "Base64 Ed25519 仓库公钥(32 字节)",
trustRepository: "信任仓库",
noInstalledPluginsDescription: "安装已签名的 .dbxp,或仅在本地开发时明确允许未签名包。",
compatible: "兼容",
blocked: "已阻止",
rollback: "回滚",
uninstall: "卸载",
rollbackConfirm: "将此插件回滚到上一个已安装版本?",
rollbackSuccess: "已回滚到 {version}",
uninstallConfirm: "卸载 {name}?",
uninstallSuccess: "已卸载 {name}",
installSuccess: "已安装 {name} {version}",
selectConnectionProvider: "请先选择连接提供者",
connectionProvider: "连接提供者",
newConnection: "新建",
workbenches: "工作台",
filesystemProviders: "文件系统提供者",
open: "打开",
browse: "浏览",
parentDirectory: "上级目录",
filesystemUri: "文件系统 URI",
refresh: "刷新",
loadingFiles: "正在加载文件",
emptyDirectory: "此目录为空。",
loadMore: "加载更多",
fileManagerHint: "双击目录可打开,双击文件可预览最多 256 KB 的内容。",
loadingPreview: "正在加载预览",
previewTruncated: "预览已在 256 KB 处截断",
previewSelectedFileHint: "双击所选文件以加载预览。",
entryCannotPreview: "此项目无法预览。",
providerNoReadCapability: "该提供者未声明读取能力。",
filesystemUnavailable: "插件文件系统“{pluginId}/{providerId}”不可用",
loadingFilesystem: "正在加载插件文件系统",
filesystemUnavailableFallback: "插件文件系统不可用",
workbenchUnavailable: "插件工作台“{pluginId}/{contributionId}”不可用",
loadingWorkbench: "正在加载插件工作台",
workbenchUnavailableFallback: "插件工作台不可用",
pluginIncompatible: "插件不兼容",
loadingTitle: "正在加载 {title}",
},
auth: {
rateLimited: "请 {seconds} 秒后再试",
setupTitle: "设置访问密码",
@@ -154,6 +253,7 @@ export default withEnglishFallback({
sqlOpenFailed: "打开文件失败:{message}",
sqlSaveFailed: "保存文件失败:{message}",
driverManager: "驱动管理",
pluginCenter: "插件中心",
updatableDriverCount: "可更新驱动数量",
mcpUpdateAvailable: "MCP 服务有可用更新",
blockDangerousRedisCommands: "拦截危险命令",
@@ -929,6 +1029,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "时序数据库",
databaseCategoryMq: "消息队列",
databaseCategoryRegistryConfig: "注册与配置",
databaseCategoryPlugins: "插件",
pluginProviderUnavailable: "此插件连接提供者不可用,请重新安装或启用插件后再试。",
pluginRequiredField: "请填写{field}",
jdbcConnection: "JDBC 连接",
iconView: "图标视图",
listView: "列表视图",
@@ -7942,6 +8045,13 @@ export default withEnglishFallback({
localInstall: "本地安装",
localInstallHint: "适用于完全内网环境:在另一台可联网的电脑上,从 DBX 的 GitHub Releases 页面下载官方 dbx-jdbc-plugin-*.zip 安装包,拷贝到本机后在此选择该文件——本机无需联网即可完成安装。",
runtimeDrivers: "运行时",
pluginsTab: "插件",
pluginContributionsTitle: "前端插件贡献",
pluginContributionsDescription: "已安装插件可以声明连接表单和其他 UI 贡献点,不需要把第三方代码注入 DBX 主窗口。",
noInstalledPlugins: "暂无已安装插件",
noInstalledPluginsDescription: "安装后端插件后,可以在这里预览它声明的前端贡献。",
pluginHostPreview: "宿主 API 预览",
pluginNoFrontendContribution: "这个插件暂时还没有声明前端贡献点。",
storageTab: "存储",
runtimeTitle: "驱动运行时",
runtimeHint: "仅在打开此面板时采集运行时指标。",
+110
View File
@@ -166,6 +166,105 @@ export default withEnglishFallback({
name: "DBX",
},
docs,
pluginPlatform: {
openResultView: "用 {label} 開啟",
centerTitle: "外掛中心",
centerDescription: "在統一商店中探索選用的 DBX 能力,並分別管理已安裝外掛與外掛倉庫。",
marketplace: "外掛商店",
installed: "已安裝",
settings: "設定",
searchMarketplace: "搜尋外掛、發佈者或標籤",
allRepositories: "全部倉庫",
loadingMarketplace: "正在載入外掛商店",
noMarketplacePlugins: "沒有可顯示的外掛",
noMarketplacePluginsDescription: "請在設定中配置外掛倉庫,或調整目前的搜尋與篩選條件。",
verified: "已驗證",
noDescription: "開發者未提供說明。",
permissionsCount: "{count} 項權限",
unsupportedTarget: "暫不支援 {target}",
installedVersion: "已安裝 v{version}",
licenseUnknown: "未指定授權條款",
sourceRepository: "原始碼倉庫",
pluginHomepage: "主頁",
marketplaceStatus: { install: "安裝", installed: "已安裝", update: "更新", unsupported: "不支援" },
updateSuccess: "已將 {name} 更新至 {version}",
noInstalledPlugins: "尚未安裝外掛",
browseMarketplace: "瀏覽外掛商店",
localInstallTitle: "安裝本機外掛套件",
localInstallDescription: "安裝從外掛商店之外取得的已簽署 .dbxp,DBX 會自動驗證套件完整性與倉庫簽章。",
repositoriesTitle: "外掛倉庫",
repositoriesDescription: "官方來源由 DBX 管理;加入自託管或第三方倉庫後,才會顯示對應的倉庫信任設定。",
repositoryFieldsRequired: "倉庫 ID、名稱和目錄網址皆為必填",
repositorySaved: "已儲存外掛倉庫「{name}」",
removeRepositoryConfirm: "移除外掛倉庫「{name}」?已安裝的外掛不會被解除安裝。",
repositoryRemoved: "已移除外掛倉庫「{name}」",
repositoryKind: { official: "官方", custom: "自訂", enterprise: "企業" },
repositoryNotConfigured: "目前版本未配置目錄網址",
enabled: "已啟用",
disabled: "已停用",
enable: "啟用",
disable: "停用",
repositoryIdPlaceholder: "倉庫 ID",
repositoryNamePlaceholder: "倉庫名稱",
repositoryCatalogUrlPlaceholder: "https://plugins.example.com/catalog/index.json",
addRepository: "加入倉庫",
signedPackagesVerifiedAutomatically: "自動驗證完整性與簽章",
developerOptionsTitle: "第三方與開發者選項",
developerOptionsDescription: "在接入第三方倉庫或開發本機外掛時使用。",
developerOptionsWarning: "未簽署外掛可以使用目前使用者權限執行原生程式碼。只應為自行建置且確認可信的開發套件啟用。",
connectionManagedInDialog: "連線統一在「新增連線」對話框中建立與編輯;此處僅負責外掛管理與能力展示。",
allowUnsignedDevelopmentPackage: "允許安裝未簽署開發套件",
allowUnsignedDevelopmentPackageDescription: "僅影響手動安裝的 .dbxp,不會放寬官方外掛商店的簽章驗證。",
installPackage: "安裝 .dbxp",
dropToInstall: "放開以安裝外掛套件",
dropInstallHint: "也可拖放 .dbxp 到外掛中心頁面安裝",
packageFileType: "DBX 外掛套件",
repositoryTrustTitle: "自訂倉庫信任",
repositoryTrustDescription: "自訂倉庫營運方使用一個倉庫金鑰簽署其審核通過的外掛。請透過獨立可信管道取得倉庫公鑰。",
repositoryKeyAdded: "已新增倉庫簽署金鑰「{keyId}」",
removeRepositoryKeyConfirm: "移除倉庫簽署金鑰「{keyId}」?已安裝的外掛不會被解除安裝。",
repositoryKeyRemoved: "已移除倉庫簽署金鑰「{keyId}」",
repositoryKeyIdPlaceholder: "倉庫金鑰 ID",
repositoryPublicKeyPlaceholder: "Base64 Ed25519 倉庫公鑰(32 位元組)",
trustRepository: "信任倉庫",
noInstalledPluginsDescription: "安裝已簽署的 .dbxp,或僅在本機開發時明確允許未簽署套件。",
compatible: "相容",
blocked: "已阻擋",
rollback: "回復版本",
uninstall: "解除安裝",
rollbackConfirm: "將此外掛回復到上一個已安裝版本?",
rollbackSuccess: "已回復到 {version}",
uninstallConfirm: "解除安裝 {name}?",
uninstallSuccess: "已解除安裝 {name}",
installSuccess: "已安裝 {name} {version}",
selectConnectionProvider: "請先選擇連線提供者",
connectionProvider: "連線提供者",
newConnection: "新增",
workbenches: "工作台",
filesystemProviders: "檔案系統提供者",
open: "開啟",
browse: "瀏覽",
parentDirectory: "上層目錄",
filesystemUri: "檔案系統 URI",
refresh: "重新整理",
loadingFiles: "正在載入檔案",
emptyDirectory: "此目錄為空。",
loadMore: "載入更多",
fileManagerHint: "按兩下目錄可開啟,按兩下檔案可預覽最多 256 KB 的內容。",
loadingPreview: "正在載入預覽",
previewTruncated: "預覽已在 256 KB 處截斷",
previewSelectedFileHint: "按兩下所選檔案以載入預覽。",
entryCannotPreview: "此項目無法預覽。",
providerNoReadCapability: "此提供者未宣告讀取能力。",
filesystemUnavailable: "外掛檔案系統「{pluginId}/{providerId}」無法使用",
loadingFilesystem: "正在載入外掛檔案系統",
filesystemUnavailableFallback: "外掛檔案系統無法使用",
workbenchUnavailable: "外掛工作台「{pluginId}/{contributionId}」無法使用",
loadingWorkbench: "正在載入外掛工作台",
workbenchUnavailableFallback: "外掛工作台無法使用",
pluginIncompatible: "外掛不相容",
loadingTitle: "正在載入 {title}",
},
auth: {
rateLimited: "請 {seconds} 秒後再試",
setupTitle: "設定存取密碼",
@@ -231,6 +330,7 @@ export default withEnglishFallback({
sqlOpenFailed: "開啟檔案失敗:{message}",
sqlSaveFailed: "儲存檔案失敗:{message}",
driverManager: "驅動程式管理器",
pluginCenter: "外掛中心",
updatableDriverCount: "可更新驅動程式數量",
mcpUpdateAvailable: "MCP 服務有可用更新",
blockDangerousRedisCommands: "攔截危險命令",
@@ -814,6 +914,9 @@ export default withEnglishFallback({
databaseCategoryTimeseries: "時序資料庫",
databaseCategoryMq: "訊息佇列",
databaseCategoryRegistryConfig: "註冊與設定",
databaseCategoryPlugins: "外掛",
pluginProviderUnavailable: "此外掛連線提供者不可用,請重新安裝或啟用外掛後再試。",
pluginRequiredField: "請填寫{field}",
jdbcConnection: "JDBC 連線",
iconView: "圖示檢視",
listView: "清單檢視",
@@ -6822,6 +6925,13 @@ export default withEnglishFallback({
localInstall: "本機安裝",
localInstallHint: "適用於完全內網環境:在另一台可連網的電腦上,從 DBX 的 GitHub Releases 頁面下載官方 dbx-jdbc-plugin-*.zip 安裝包,傳輸到本機後在此選取該檔案——本機不需要連網即可完成安裝。",
runtimeDrivers: "執行環境",
pluginsTab: "外掛程式",
pluginContributionsTitle: "前端外掛程式貢獻",
pluginContributionsDescription: "已安裝的外掛程式可以描述連線表單和其他 UI 貢獻,而無需將程式碼注入 DBX 主視窗。",
noInstalledPlugins: "尚未安裝外掛程式",
noInstalledPluginsDescription: "安裝後端外掛程式後,可在此預覽其前端貢獻。",
pluginHostPreview: "宿主 API 預覽",
pluginNoFrontendContribution: "此外掛程式尚未宣告前端貢獻。",
storageTab: "儲存",
runtimeTitle: "驅動程式執行環境",
runtimeHint: "僅在開啟此面板時收集執行環境指標。",
@@ -177,4 +177,62 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.originalSql).toBe("SELECT 1");
expect(restored.externalSqlFileMissing).toBe(true);
});
it("preserves plugin workbench identity and connection-safe context", () => {
const [restored] = roundTrip([
queryTab({
id: "plugin-tab",
title: "Hello connection · Workbench",
connectionId: "plugin-connection",
database: "",
mode: "plugin-workbench",
pluginWorkbench: {
pluginId: "dbx.example.hello",
contributionId: "dbx.example.hello.main",
context: {
connectionId: "plugin-connection",
providerId: "hello.connection",
connectionType: "hello",
},
},
}),
]);
expect(restored.mode).toBe("plugin-workbench");
expect(restored.pluginWorkbench).toEqual({
pluginId: "dbx.example.hello",
contributionId: "dbx.example.hello.main",
context: {
connectionId: "plugin-connection",
providerId: "hello.connection",
connectionType: "hello",
},
});
});
it("preserves host-owned plugin filesystem navigation", () => {
const [restored] = roundTrip([
queryTab({
id: "plugin-files",
title: "Object storage · Files",
connectionId: "plugin-connection",
database: "",
mode: "plugin-filesystem",
pluginFilesystem: {
pluginId: "dbx.example.storage",
providerId: "dbx.example.storage.files",
rootUri: "s3://bucket/",
currentUri: "s3://bucket/reports/",
},
}),
]);
expect(restored.mode).toBe("plugin-filesystem");
expect(restored.pluginFilesystem).toEqual({
pluginId: "dbx.example.storage",
providerId: "dbx.example.storage.files",
rootUri: "s3://bucket/",
currentUri: "s3://bucket/reports/",
});
});
});
@@ -33,7 +33,7 @@ describe("ConnectionDialog service deep-link hydration", () => {
});
it("keeps failed one-time connections available for error inspection and retry", () => {
const saveStart = source.indexOf("async function save()");
const saveStart = source.indexOf("async function save(");
const saveEnd = source.indexOf('const dialogTitle = ref("")', saveStart);
const saveBody = source.slice(saveStart, saveEnd);
@@ -22,6 +22,12 @@ describe("ConnectionDialog driver profile configuration", () => {
const submitConfig = connectionConfigForSubmitSource();
expect(submitConfig).toContain("setGaussdbTargetServerType(config, targetServerType)");
expect(submitConfig).toContain("else if (!isDoltDriverProfile(config.driver_profile))");
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
});
it("preserves plugin external configuration on submit", () => {
const submitConfig = connectionConfigForSubmitSource();
expect(submitConfig).toContain('else if (config.db_type !== "plugin" && !isDoltDriverProfile(config.driver_profile))');
});
});
@@ -0,0 +1,58 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const appSource = readFileSync(new URL("../../../App.vue", import.meta.url), "utf8");
const toolbarSource = readFileSync(new URL("../../../components/layout/AppToolbar.vue", import.meta.url), "utf8");
const tabBarSource = readFileSync(new URL("../../../components/layout/AppTabBar.vue", import.meta.url), "utf8");
const driverStoreSource = readFileSync(new URL("../../../components/config/DriverStoreDialog.vue", import.meta.url), "utf8");
const appDialogsSource = readFileSync(new URL("../../../components/layout/AppDialogs.vue", import.meta.url), "utf8");
const connectionDialogSource = readFileSync(new URL("../../../components/connection/ConnectionDialog.vue", import.meta.url), "utf8");
const pluginCenterSource = readFileSync(new URL("../../../components/plugins/PluginContributionsPanel.vue", import.meta.url), "utf8");
describe("plugin center integration", () => {
it("opens from the top toolbar as an independent app surface", () => {
expect(toolbarSource).toContain("toolbarItems.pluginCenter");
expect(toolbarSource).toContain("emit('open-plugin-center')");
expect(appSource).toContain('@open-plugin-center="openPluginCenterPage()"');
expect(appSource).toContain("<PluginCenterPage");
expect(tabBarSource).toContain("data-plugin-center-tab");
});
it("keeps plugin management out of Driver Manager", () => {
expect(driverStoreSource).not.toContain('value="plugins"');
expect(driverStoreSource).not.toContain("PluginContributionsPanel");
});
it("creates and edits plugin connections in the unified connection dialog", () => {
expect(appDialogsSource).toContain(':edit-config="editConfig"');
expect(appDialogsSource).toContain(':plugin-provider="connectionPluginProvider"');
expect(appDialogsSource).not.toContain("pluginEditConfig");
expect(connectionDialogSource).toContain("pluginConnectionProviderOptionValue");
expect(connectionDialogSource).toContain("<PluginConnectionFields");
expect(connectionDialogSource).toContain("buildPluginConnectionConfig");
});
it("keeps marketplace security automatic and developer controls hidden", () => {
const template = pluginCenterSource.split("<template>")[1];
const settingsIndex = template.indexOf('value="settings"');
const developerOptionsIndex = template.indexOf("pluginPlatform.developerOptionsTitle");
const allowUnsignedIndex = template.indexOf("pluginPlatform.allowUnsignedDevelopmentPackage");
expect(template).toContain('value="marketplace"');
expect(template).toContain('value="installed"');
expect(template).toContain('value="settings"');
expect(settingsIndex).toBeLessThan(developerOptionsIndex);
expect(developerOptionsIndex).toBeLessThan(allowUnsignedIndex);
expect(template).toContain("pluginPlatform.signedPackagesVerifiedAutomatically");
expect(template).toContain('<template v-if="showCustomRepositoryTrustSettings">');
expect(pluginCenterSource).toContain("customRepositories.value.length > 0 || trustedKeys.value.length > 0");
expect(template).not.toContain("pluginPlatform.advancedSecurityTitle");
expect(pluginCenterSource).toContain("fetchPluginMarketplaceCatalogs");
expect(template).not.toContain("pluginPlatform.centerTitle");
expect(template).not.toContain("marketplaceTarget");
expect(template).not.toContain("<select");
expect(template).toContain("<SelectTrigger");
expect(template).not.toContain("max-w-md grid-cols-3");
expect(template).not.toContain("marketplaceCategory");
expect(template).toContain("repeat(auto-fit");
});
});
@@ -47,6 +47,8 @@ export interface SavedOpenTab {
whereInput?: string;
pinned?: boolean;
mode?: QueryTab["mode"];
pluginWorkbench?: QueryTab["pluginWorkbench"];
pluginFilesystem?: QueryTab["pluginFilesystem"];
autoCommit?: boolean;
mqTenant?: string;
mqInitialTab?: QueryTab["mqInitialTab"];
@@ -180,6 +182,8 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.whereInput !== undefined ? { whereInput: tab.whereInput } : {}),
pinned: tab.pinned,
mode: tab.mode,
...(tab.pluginWorkbench ? { pluginWorkbench: tab.pluginWorkbench } : {}),
...(tab.pluginFilesystem ? { pluginFilesystem: tab.pluginFilesystem } : {}),
...(tab.mode === "query" && tab.autoCommit !== undefined ? { autoCommit: tab.autoCommit } : {}),
...(tab.mqTenant !== undefined ? { mqTenant: tab.mqTenant } : {}),
...(tab.mqInitialTab !== undefined ? { mqInitialTab: tab.mqInitialTab } : {}),
@@ -213,6 +217,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}),
...(tab.uiState ? { uiState: sanitizeTabUiState(tab.uiState) } : {}),
...(tab.mode === "query" && tab.resultAutoSave ? { resultAutoSave: true } : {}),
}));
}
+28
View File
@@ -79,6 +79,34 @@ export const readKeychainPassword = forward("readKeychainPassword");
export const readKeychainPasswords = forward("readKeychainPasswords");
export const decryptConfig = forward("decryptConfig");
export const listPlugins = forward("listPlugins");
export const listPluginTrustedKeys = forward("listPluginTrustedKeys");
export const savePluginTrustedKey = forward("savePluginTrustedKey");
export const removePluginTrustedKey = forward("removePluginTrustedKey");
export const listPluginRepositories = forward("listPluginRepositories");
export const savePluginRepository = forward("savePluginRepository");
export const removePluginRepository = forward("removePluginRepository");
export const fetchPluginMarketplaceCatalogs = forward("fetchPluginMarketplaceCatalogs");
export const installMarketplacePlugin = forward("installMarketplacePlugin");
export const installPluginPackage = forward("installPluginPackage");
export const rollbackPlugin = forward("rollbackPlugin");
export const uninstallPlugin = forward("uninstallPlugin");
export const activatePlugin = forward("activatePlugin");
export const listActivePlugins = forward("listActivePlugins");
export const stopPlugin = forward("stopPlugin");
export const invokePlugin = forward("invokePlugin");
export const invokePluginConnectionAction = forward("invokePluginConnectionAction");
export const notifyPlugin = forward("notifyPlugin");
export const sendPluginBinary = forward("sendPluginBinary");
export const listPluginFilesystemEntries = forward("listPluginFilesystemEntries");
export const readPluginFilesystemFile = forward("readPluginFilesystemFile");
export const writePluginFilesystemFile = forward("writePluginFilesystemFile");
export const createPluginFilesystemDirectory = forward("createPluginFilesystemDirectory");
export const deletePluginFilesystemEntry = forward("deletePluginFilesystemEntry");
export const renamePluginFilesystemEntry = forward("renamePluginFilesystemEntry");
export const readPluginAsset = forward("readPluginAsset");
export const readPluginUiEntry = forward("readPluginUiEntry");
export const readPluginUiAsset = forward("readPluginUiAsset");
export const subscribePluginEvents = forward("subscribePluginEvents");
export const listJdbcDrivers = forward("listJdbcDrivers");
export const listJdbcMavenBundles = forward("listJdbcMavenBundles");
export const listJdbcLocalBundles = forward("listJdbcLocalBundles");
+168
View File
@@ -203,6 +203,22 @@ import type { BuildDatabaseSqlExportOptions, BuildExportInsertStatementsOptions
import { loadBrowserAppState, saveBrowserAppState } from "@/lib/backend/browserAppStateStorage";
import type { DataCompareFromTablesOptions, DataCompareFromTablesPreparation, DataCompareSyncPlan, DataCompareSyncPlanOptions, DataComparePreparation, DataComparePreparationOptions } from "@/lib/dataGrid/dataCompare";
import { apiUrl, apiWebSocketUrl } from "@/lib/common/webPath";
import type {
ActivePluginSession,
PluginBinaryEvent,
PluginConnectionActionResult,
PluginEvent,
PluginFilesystemListResult,
PluginFilesystemMutationResult,
PluginFilesystemReadResult,
PluginInstallResult,
PluginMarketplaceInstallRequest,
PluginRepository,
PluginRepositoryCatalogResult,
PluginRollbackResult,
PluginTrustedKey,
PluginUiAssetPayload,
} from "@/types/database";
import type { DataGridSavePreparation } from "@/lib/backend/tauri";
import type {
NacosBatchPreview,
@@ -519,6 +535,158 @@ export async function listPlugins(): Promise<InstalledPlugin[]> {
return get("/api/plugins");
}
export async function listPluginTrustedKeys(): Promise<PluginTrustedKey[]> {
return get("/api/plugins/trusted-keys");
}
export async function savePluginTrustedKey(keyId: string, publicKey: string): Promise<PluginTrustedKey[]> {
return post("/api/plugins/trusted-keys/save", { keyId, publicKey });
}
export async function removePluginTrustedKey(keyId: string): Promise<PluginTrustedKey[]> {
return post("/api/plugins/trusted-keys/remove", { keyId });
}
export async function listPluginRepositories(): Promise<PluginRepository[]> {
return get("/api/plugins/repositories");
}
export async function savePluginRepository(repository: PluginRepository): Promise<PluginRepository[]> {
return post("/api/plugins/repositories/save", repository);
}
export async function removePluginRepository(repositoryId: string): Promise<PluginRepository[]> {
return post("/api/plugins/repositories/remove", { repositoryId });
}
export async function fetchPluginMarketplaceCatalogs(): Promise<PluginRepositoryCatalogResult[]> {
return get("/api/plugins/marketplace/catalogs");
}
export async function installMarketplacePlugin(request: PluginMarketplaceInstallRequest): Promise<PluginInstallResult> {
return post("/api/plugins/marketplace/install", request);
}
export async function installPluginPackage(pathOrFile: string | File, allowUnsigned = false): Promise<PluginInstallResult> {
let blob: Blob;
let fileName: string;
if (pathOrFile instanceof File) {
blob = pathOrFile;
fileName = pathOrFile.name;
} else {
fileName = pathOrFile.split("/").pop() || "plugin.dbxp";
blob = await (await fetch(pathOrFile)).blob();
}
const formData = new FormData();
formData.append("file", blob, fileName);
const response = await fetch(apiUrl(`/api/plugins/install?allow_unsigned=${allowUnsigned}`), { method: "POST", body: formData });
if (!response.ok) throw new Error(await response.text());
return response.json();
}
export async function rollbackPlugin(pluginId: string): Promise<PluginRollbackResult> {
return post("/api/plugins/rollback", { plugin_id: pluginId });
}
export async function uninstallPlugin(pluginId: string): Promise<InstalledPlugin[]> {
return post("/api/plugins/uninstall", { plugin_id: pluginId });
}
export async function activatePlugin(pluginId: string): Promise<ActivePluginSession[]> {
return post("/api/plugins/activate", { plugin_id: pluginId });
}
export async function listActivePlugins(): Promise<ActivePluginSession[]> {
return get("/api/plugins/active");
}
export async function stopPlugin(pluginId: string): Promise<void> {
await post("/api/plugins/stop", { plugin_id: pluginId });
}
export async function invokePlugin<T = unknown>(pluginId: string, method: string, params: unknown = null, timeoutMs?: number): Promise<T> {
return post("/api/plugins/invoke", { pluginId, method, params, timeoutMs });
}
export async function invokePluginConnectionAction(config: ConnectionConfig, actionId: string): Promise<PluginConnectionActionResult> {
return post("/api/plugins/connection-action", { config, actionId });
}
export async function notifyPlugin(pluginId: string, method: string, params: unknown = null): Promise<void> {
await post("/api/plugins/notify", { pluginId, method, params });
}
export async function sendPluginBinary(pluginId: string, channel: string, dataBase64: string): Promise<void> {
await post("/api/plugins/binary", { pluginId, channel, dataBase64 });
}
export async function listPluginFilesystemEntries(pluginId: string, providerId: string, options: { connectionId?: string; uri?: string; cursor?: string; limit?: number } = {}): Promise<PluginFilesystemListResult> {
return post("/api/plugins/filesystem/list", { pluginId, providerId, ...options });
}
export async function readPluginFilesystemFile(pluginId: string, providerId: string, uri: string, options: { connectionId?: string; maxBytes?: number } = {}): Promise<PluginFilesystemReadResult> {
return post("/api/plugins/filesystem/read", { pluginId, providerId, uri, ...options });
}
export async function writePluginFilesystemFile(pluginId: string, providerId: string, uri: string, dataBase64: string, options: { connectionId?: string; create?: boolean; overwrite?: boolean; etag?: string } = {}): Promise<PluginFilesystemMutationResult> {
return post("/api/plugins/filesystem/write", { pluginId, providerId, uri, dataBase64, create: options.create === true, overwrite: options.overwrite === true, connectionId: options.connectionId, etag: options.etag });
}
export async function createPluginFilesystemDirectory(pluginId: string, providerId: string, uri: string, connectionId?: string): Promise<PluginFilesystemMutationResult> {
return post("/api/plugins/filesystem/create-directory", { pluginId, providerId, uri, connectionId });
}
export async function deletePluginFilesystemEntry(pluginId: string, providerId: string, uri: string, options: { connectionId?: string; recursive?: boolean } = {}): Promise<PluginFilesystemMutationResult> {
return post("/api/plugins/filesystem/delete", { pluginId, providerId, uri, connectionId: options.connectionId, recursive: options.recursive === true });
}
export async function renamePluginFilesystemEntry(pluginId: string, providerId: string, sourceUri: string, targetUri: string, options: { connectionId?: string; overwrite?: boolean } = {}): Promise<PluginFilesystemMutationResult> {
return post("/api/plugins/filesystem/rename", { pluginId, providerId, sourceUri, targetUri, connectionId: options.connectionId, overwrite: options.overwrite === true });
}
export async function readPluginAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload> {
const encodedPath = path
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return pluginAssetPayload(`/api/plugins/${encodeURIComponent(pluginId)}/assets/${encodedPath}`);
}
export async function readPluginUiEntry(pluginId: string): Promise<PluginUiAssetPayload> {
return pluginAssetPayload(`/api/plugins/${encodeURIComponent(pluginId)}/ui`);
}
export async function readPluginUiAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload> {
const encodedPath = path
.split("/")
.map((part) => encodeURIComponent(part))
.join("/");
return pluginAssetPayload(`/api/plugins/${encodeURIComponent(pluginId)}/ui/${encodedPath}`);
}
export async function subscribePluginEvents(onEvent: (event: PluginEvent) => void, onBinary?: (event: PluginBinaryEvent) => void): Promise<() => void> {
const source = new EventSource(apiUrl("/api/plugins/events"));
source.onmessage = (message) => {
const payload = JSON.parse(message.data) as ({ kind: "event" } & PluginEvent) | ({ kind: "binary" } & PluginBinaryEvent) | { kind: "lagged" };
if (payload.kind === "event") onEvent(payload);
if (payload.kind === "binary") onBinary?.(payload);
};
return () => source.close();
}
async function pluginAssetPayload(path: string): Promise<PluginUiAssetPayload> {
const response = await fetch(apiUrl(path));
if (!response.ok) throw new Error(await response.text());
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return {
contentType: response.headers.get("content-type") || "application/octet-stream",
dataBase64: btoa(binary),
etag: response.headers.get("etag") || "",
};
}
export async function listJdbcDrivers(): Promise<JdbcDriverInfo[]> {
return get("/api/jdbc/drivers");
}
+134
View File
@@ -82,6 +82,22 @@ import type { SidebarObjectKind } from "@/lib/database/databaseObjectCapabilitie
import type { AiChatSelectionState, AiConfig, AiConfigItem, AiEffortCapability, AiEffortLevel, AiTestConnectionResult } from "@/types/ai";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
import { isTerminalTransferProgress } from "@/lib/backend/transferProgress";
import type {
ActivePluginSession,
PluginBinaryEvent,
PluginConnectionActionResult,
PluginEvent,
PluginFilesystemListResult,
PluginFilesystemMutationResult,
PluginFilesystemReadResult,
PluginInstallResult,
PluginMarketplaceInstallRequest,
PluginRepository,
PluginRepositoryCatalogResult,
PluginRollbackResult,
PluginTrustedKey,
PluginUiAssetPayload,
} from "@/types/database";
import type {
DataGridColumnDistinctValuesSqlOptions,
DataGridColumnValueFilterConditionOptions,
@@ -2234,6 +2250,124 @@ export async function listPlugins(): Promise<InstalledPlugin[]> {
return invoke("list_plugins");
}
export async function listPluginTrustedKeys(): Promise<PluginTrustedKey[]> {
return invoke("list_plugin_trusted_keys");
}
export async function savePluginTrustedKey(keyId: string, publicKey: string): Promise<PluginTrustedKey[]> {
return invoke("save_plugin_trusted_key", { keyId, publicKey });
}
export async function removePluginTrustedKey(keyId: string): Promise<PluginTrustedKey[]> {
return invoke("remove_plugin_trusted_key", { keyId });
}
export async function listPluginRepositories(): Promise<PluginRepository[]> {
return invoke("list_plugin_repositories");
}
export async function savePluginRepository(repository: PluginRepository): Promise<PluginRepository[]> {
return invoke("save_plugin_repository", { repository });
}
export async function removePluginRepository(repositoryId: string): Promise<PluginRepository[]> {
return invoke("remove_plugin_repository", { repositoryId });
}
export async function fetchPluginMarketplaceCatalogs(): Promise<PluginRepositoryCatalogResult[]> {
return invoke("fetch_plugin_marketplace_catalogs");
}
export async function installMarketplacePlugin(request: PluginMarketplaceInstallRequest): Promise<PluginInstallResult> {
return invoke("install_marketplace_plugin", { request });
}
export async function installPluginPackage(pathOrFile: string | File, allowUnsigned = false): Promise<PluginInstallResult> {
if (typeof pathOrFile !== "string") throw new Error("Desktop plugin installation requires a local .dbxp file path");
return invoke("install_plugin_package", { path: pathOrFile, allowUnsigned });
}
export async function rollbackPlugin(pluginId: string): Promise<PluginRollbackResult> {
return invoke("rollback_plugin", { pluginId });
}
export async function uninstallPlugin(pluginId: string): Promise<InstalledPlugin[]> {
return invoke("uninstall_plugin", { pluginId });
}
export async function activatePlugin(pluginId: string): Promise<ActivePluginSession[]> {
return invoke("activate_plugin", { pluginId });
}
export async function listActivePlugins(): Promise<ActivePluginSession[]> {
return invoke("list_active_plugins");
}
export async function stopPlugin(pluginId: string): Promise<void> {
return invoke("stop_plugin", { pluginId });
}
export async function invokePlugin<T = unknown>(pluginId: string, method: string, params: unknown = null, timeoutMs?: number): Promise<T> {
return invoke("invoke_plugin", { pluginId, method, params, timeoutMs });
}
export async function invokePluginConnectionAction(config: ConnectionConfig, actionId: string): Promise<PluginConnectionActionResult> {
return invoke("invoke_plugin_connection_action", { config, actionId });
}
export async function notifyPlugin(pluginId: string, method: string, params: unknown = null): Promise<void> {
return invoke("notify_plugin", { pluginId, method, params });
}
export async function sendPluginBinary(pluginId: string, channel: string, dataBase64: string): Promise<void> {
return invoke("send_plugin_binary", { pluginId, channel, dataBase64 });
}
export async function listPluginFilesystemEntries(pluginId: string, providerId: string, options: { connectionId?: string; uri?: string; cursor?: string; limit?: number } = {}): Promise<PluginFilesystemListResult> {
return invoke("list_plugin_filesystem_entries", { pluginId, providerId, ...options });
}
export async function readPluginFilesystemFile(pluginId: string, providerId: string, uri: string, options: { connectionId?: string; maxBytes?: number } = {}): Promise<PluginFilesystemReadResult> {
return invoke("read_plugin_filesystem_file", { pluginId, providerId, uri, ...options });
}
export async function writePluginFilesystemFile(pluginId: string, providerId: string, uri: string, dataBase64: string, options: { connectionId?: string; create?: boolean; overwrite?: boolean; etag?: string } = {}): Promise<PluginFilesystemMutationResult> {
return invoke("write_plugin_filesystem_file", { pluginId, providerId, uri, dataBase64, create: options.create === true, overwrite: options.overwrite === true, connectionId: options.connectionId, etag: options.etag });
}
export async function createPluginFilesystemDirectory(pluginId: string, providerId: string, uri: string, connectionId?: string): Promise<PluginFilesystemMutationResult> {
return invoke("create_plugin_filesystem_directory", { pluginId, providerId, uri, connectionId });
}
export async function deletePluginFilesystemEntry(pluginId: string, providerId: string, uri: string, options: { connectionId?: string; recursive?: boolean } = {}): Promise<PluginFilesystemMutationResult> {
return invoke("delete_plugin_filesystem_entry", { pluginId, providerId, uri, connectionId: options.connectionId, recursive: options.recursive === true });
}
export async function renamePluginFilesystemEntry(pluginId: string, providerId: string, sourceUri: string, targetUri: string, options: { connectionId?: string; overwrite?: boolean } = {}): Promise<PluginFilesystemMutationResult> {
return invoke("rename_plugin_filesystem_entry", { pluginId, providerId, sourceUri, targetUri, connectionId: options.connectionId, overwrite: options.overwrite === true });
}
export async function readPluginAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload> {
return invoke("read_plugin_asset", { pluginId, path });
}
export async function readPluginUiEntry(pluginId: string): Promise<PluginUiAssetPayload> {
return invoke("read_plugin_ui_entry", { pluginId });
}
export async function readPluginUiAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload> {
return invoke("read_plugin_ui_asset", { pluginId, path });
}
export async function subscribePluginEvents(onEvent: (event: PluginEvent) => void, onBinary?: (event: PluginBinaryEvent) => void): Promise<UnlistenFn> {
const unlistenEvent = await listen<PluginEvent>("dbx-plugin-event", (event) => onEvent(event.payload));
const unlistenBinary = await listen<PluginBinaryEvent>("dbx-plugin-binary", (event) => onBinary?.(event.payload));
return () => {
unlistenEvent();
unlistenBinary();
};
}
export async function listJdbcDrivers(): Promise<JdbcDriverInfo[]> {
return invoke("list_jdbc_drivers");
}
@@ -1,7 +1,7 @@
import type { ConnectionConfig } from "@/types/database";
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "consul" } | { kind: "query"; database: string };
export type QuickConnectionOpenTarget = { kind: "mq-admin" } | { kind: "nacos-admin" } | { kind: "plugin-workbench" } | { kind: "etcd" } | { kind: "zookeeper" } | { kind: "consul" } | { kind: "query"; database: string };
export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db_type" | "database">, databaseOptions: string[] = []): QuickConnectionOpenTarget {
if (connection.db_type === "mq") {
@@ -10,6 +10,9 @@ export function quickConnectionOpenTarget(connection: Pick<ConnectionConfig, "db
if (connection.db_type === "nacos") {
return { kind: "nacos-admin" };
}
if (connection.db_type === "plugin") {
return { kind: "plugin-workbench" };
}
if (connection.db_type === "etcd") {
return { kind: "etcd" };
}
@@ -5,7 +5,7 @@ const DRAFT_VISIBLE_DATABASES_PREFIX = "__visible_draft_";
// Turso and Cloudflare D1 target one fixed SQLite-compatible `main` namespace;
// non-database services expose their own root objects rather than database namespaces.
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul"]);
const UNSUPPORTED_VISIBLE_DATABASE_TYPES = new Set<DatabaseType>(["turso", "cloudflare-d1", "dynamodb", "elasticsearch", "easysearch", "meilisearch", "qdrant", "milvus", "weaviate", "chromadb", "etcd", "zookeeper", "mq", "nacos", "consul", "plugin"]);
type VisibleDatabaseConnectionFields = Pick<
ConnectionConfig,
@@ -125,6 +125,7 @@ export function supportsClearableQuerySchema(dbType?: DatabaseType): boolean {
*/
export function supportsConnectionQueryActions(dbType?: DatabaseType): boolean {
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase" && dbType !== "zookeeper" && dbType !== "mq" && dbType !== "mqtt";
return dbType !== "nacos" && dbType !== "consul" && dbType !== "hbase";
}
/**
@@ -93,6 +93,7 @@ export const DATABASE_NAMESPACE_CREATION_MATRIX = {
influxdb3: { deferred: "database creation on InfluxDB 3.x goes through the /api/v3/configure/database admin endpoint, which is not part of the SQL execution path" },
victoriametrics: { deferred: "metric namespaces are managed by VictoriaMetrics deployment configuration" },
jdbc: { deferred: "generic JDBC does not expose a reliable dialect-specific create target" },
plugin: { deferred: "plugin-owned namespaces are managed by the provider workbench" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace creation already uses the Nacos admin flow" },
consul: { deferred: "Consul namespaces and partitions are connection scopes, not KV resources" },
@@ -93,6 +93,7 @@ export const DATABASE_PROPERTY_EDITING_MATRIX = {
influxdb3: { deferred: "database retention policies need a dedicated workflow" },
victoriametrics: { deferred: "metric and retention settings are managed by VictoriaMetrics deployment configuration" },
jdbc: { deferred: "generic JDBC does not expose reliable dialect-specific properties" },
plugin: { deferred: "plugin-owned properties are managed by the provider workbench" },
mq: { deferred: "message queue namespaces are handled by MQ admin panels" },
nacos: { deferred: "Nacos namespace editing already uses the Nacos admin flow" },
consul: { deferred: "Consul KV scopes are configured on the connection" },
+330
View File
@@ -0,0 +1,330 @@
import { describe, expect, it } from "vitest";
import type { InstalledPlugin, PluginConnectionProviderContribution } from "@/types/database";
import { buildPluginConnectionConfig, createFrontendPluginRegistry, initialPluginFormValues, parsePluginConnectionProviderOptionValue, pluginConnectionActionsForDialog, pluginConnectionFormValues, pluginConnectionProviderIcon, pluginConnectionProviderOptionValue } from "./frontendPlugin";
function installedPlugin(id: string, contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
return {
compatibility: { compatible: true },
manifest: {
id,
name: id,
version: "1.0.0",
drivers: [],
contributions,
},
};
}
function connectionProvider(overrides: Partial<PluginConnectionProviderContribution> = {}): PluginConnectionProviderContribution {
return {
type: "connection-provider",
id: "connection",
label: "Example",
database_type: "example",
fields: [
{ key: "host", label: "Host", type: "text", required: true },
{ key: "port", label: "Port", type: "number", default: 1234 },
{ key: "tls", label: "TLS", type: "boolean", default: false },
],
...overrides,
};
}
describe("FrontendPluginRegistry", () => {
it("round-trips plugin connection provider picker values", () => {
const value = pluginConnectionProviderOptionValue("example/plugin", "ssh:main");
expect(parsePluginConnectionProviderOptionValue(value)).toEqual({ pluginId: "example/plugin", providerId: "ssh:main" });
expect(parsePluginConnectionProviderOptionValue("mysql")).toBeNull();
});
it("indexes declarative connection providers", () => {
const registry = createFrontendPluginRegistry([installedPlugin("com.example.plugin", [connectionProvider()])]);
expect(registry.listConnectionProviders()).toHaveLength(1);
expect(registry.listConnectionProviders()[0]?.contribution.database_type).toBe("example");
});
it("indexes workbench, connection provider, and filesystem contributions", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("com.example.plugin", [
{
type: "connection-provider",
id: "example.connection",
label: "Example",
database_type: "example",
fields: [],
workbench: "example.main",
filesystem_provider: "example.files",
},
{ type: "workbench", id: "example.main", label: "Example Workbench" },
{ type: "filesystem-provider", id: "example.files", label: "Example Files", schemes: ["example"], root_uri: "example:/home", capabilities: ["read", "write"] },
]),
]);
expect(registry.listConnectionProviders()).toHaveLength(1);
expect(registry.listWorkbenches()[0]?.contribution.id).toBe("example.main");
expect(registry.listFilesystemProviders()[0]?.contribution.schemes).toEqual(["example"]);
expect(registry.listFilesystemProviders()[0]?.contribution.root_uri).toBe("example:/home");
expect(registry.listConnectionProviders()[0]?.contribution.filesystem_provider).toBe("example.files");
});
it("indexes result-view contributions", () => {
const registry = createFrontendPluginRegistry([installedPlugin("com.example.plugin", [{ type: "result-view", id: "example.graph", label: "Graph" }])]);
const views = registry.listResultViews();
expect(views).toHaveLength(1);
expect(views[0]?.contribution.id).toBe("example.graph");
expect(views[0]?.contribution.label).toBe("Graph");
});
it("indexes context-menu contributions per menu surface", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("com.example.plugin", [
{ type: "context-menu", id: "example.inspect", label: "Inspect endpoint", menu: "connection" },
{ type: "context-menu", id: "example.other", label: "Wrong surface", menu: "tree" },
]),
]);
const items = registry.listContextMenuItems("connection");
expect(items).toHaveLength(1);
expect(items[0]?.contribution.id).toBe("example.inspect");
expect(items[0]?.plugin.manifest.id).toBe("com.example.plugin");
expect(registry.listContextMenuItems("tree")).toHaveLength(1);
expect(registry.listContextMenuItems("missing")).toHaveLength(0);
});
it("prefers provider display metadata and falls back to plugin metadata", () => {
const explicit = installedPlugin("com.example.explicit", [
{
type: "connection-provider",
id: "explicit.connection",
label: "Explicit connection",
icon: "assets/provider.svg",
database_type: "explicit",
fields: [],
},
]);
explicit.manifest.name = "Explicit plugin";
explicit.manifest.icon = "assets/plugin.svg";
const fallback = installedPlugin("com.example.fallback", [
{
type: "connection-provider",
id: "fallback.connection",
label: "",
database_type: "fallback",
fields: [],
},
]);
fallback.manifest.name = "Fallback plugin";
fallback.manifest.icon = "assets/fallback.svg";
const registry = createFrontendPluginRegistry([explicit, fallback]);
const [explicitEntry, fallbackEntry] = registry.listConnectionProviders();
expect(explicitEntry?.contribution.label).toBe("Explicit connection");
expect(pluginConnectionProviderIcon(explicitEntry!)).toBe("assets/provider.svg");
expect(fallbackEntry?.contribution.label).toBe("Fallback plugin");
expect(pluginConnectionProviderIcon(fallbackEntry!)).toBe("assets/fallback.svg");
});
it("falls back to provider id and the generic icon when metadata is absent", () => {
const plugin = installedPlugin("com.example.minimal", [
{
type: "connection-provider",
id: "minimal.connection",
label: "",
database_type: "minimal",
fields: [],
},
]);
plugin.manifest.name = "";
const entry = createFrontendPluginRegistry([plugin]).listConnectionProviders()[0]!;
expect(entry.contribution.label).toBe("minimal.connection");
expect(pluginConnectionProviderIcon(entry)).toBeUndefined();
});
it("drops unsafe icon paths before rendering", () => {
const plugin = installedPlugin("com.example.unsafe", [
{
type: "connection-provider",
id: "unsafe.connection",
label: "Unsafe",
icon: "../outside.svg",
database_type: "unsafe",
fields: [],
},
]);
plugin.manifest.icon = "\\outside.svg";
const definition = createFrontendPluginRegistry([plugin]).listPlugins()[0]!;
const entry = createFrontendPluginRegistry([plugin]).listConnectionProviders()[0]!;
expect(definition.plugin.manifest.icon).toBeUndefined();
expect(pluginConnectionProviderIcon(entry)).toBeUndefined();
});
it("localizes plugin metadata, contributions, and form fields", () => {
const plugin = installedPlugin("com.example.plugin", [connectionProvider({ id: "example.connection", description: "English description" })]);
plugin.manifest.localizations = {
"zh-CN": {
name: "示例插件",
description: "插件说明",
contributions: {
"example.connection": {
label: "示例连接",
description: "连接说明",
fields: {
host: { label: "主机", placeholder: "请输入主机" },
},
},
},
},
};
const registry = createFrontendPluginRegistry([plugin], "zh-CN");
const definition = registry.listPlugins()[0]!;
const contribution = registry.listConnectionProviders()[0]!.contribution;
expect(definition.plugin.manifest.name).toBe("示例插件");
expect(definition.plugin.manifest.description).toBe("插件说明");
expect(contribution.label).toBe("示例连接");
expect(contribution.description).toBe("连接说明");
expect(contribution.fields[0]).toMatchObject({ label: "主机", placeholder: "请输入主机" });
expect(plugin.manifest.name).toBe("com.example.plugin");
});
it("normalizes connection actions in manifest order and localizes their labels", () => {
const plugin = installedPlugin("com.example.actions", [
{
type: "connection-provider",
id: "example.connection",
label: "Example",
database_type: "example",
fields: [],
capabilities: ["test"],
actions: [
{
id: "discover",
label: "Discover",
description: "Discover an endpoint",
variant: "outline",
requires_valid_form: false,
},
{ id: "edit", label: "Edit", variant: "secondary", when: "edit" },
],
},
]);
plugin.manifest.localizations = {
"zh-CN": {
contributions: {
"example.connection": {
actions: {
discover: { label: "发现地址", description: "自动发现连接地址" },
},
},
},
},
};
const actions = createFrontendPluginRegistry([plugin], "zh-CN").listConnectionProviders()[0]?.contribution.actions;
expect(actions?.map((action) => action.id)).toEqual(["discover", "edit"]);
expect(actions?.[0]).toMatchObject({
label: "发现地址",
description: "自动发现连接地址",
requires_valid_form: false,
});
expect(actions?.[1]).toMatchObject({ label: "Edit", variant: "secondary", when: "edit" });
});
it("treats omitted and explicitly empty custom action lists equivalently", () => {
const registry = createFrontendPluginRegistry([
installedPlugin("com.example.defaults", [{ type: "connection-provider", id: "default.connection", label: "Default", database_type: "default", fields: [] }]),
installedPlugin("com.example.empty", [{ type: "connection-provider", id: "empty.connection", label: "Empty", database_type: "empty", fields: [], actions: [] }]),
]);
const [defaults, empty] = registry.listConnectionProviders();
expect(defaults?.contribution.actions).toBeUndefined();
expect(empty?.contribution.actions).toEqual([]);
expect(pluginConnectionActionsForDialog(defaults!.contribution, false)).toEqual(pluginConnectionActionsForDialog(empty!.contribution, false));
});
it("builds compatible default footer actions and filters create/edit actions", () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "example.connection",
label: "Example",
database_type: "example",
fields: [],
capabilities: ["test"],
};
expect(pluginConnectionActionsForDialog(provider, false).map((action) => action.kind)).toEqual(["test", "save-and-connect"]);
expect(pluginConnectionActionsForDialog(provider, true).map((action) => action.kind)).toEqual(["test", "save"]);
expect(
pluginConnectionActionsForDialog(
{
...provider,
actions: [
{ id: "create", label: "Create", when: "create" },
{ id: "edit", label: "Edit", when: "edit" },
{ id: "always", label: "Always", when: "always" },
],
},
true,
).map((action) => action.id),
).toEqual(["edit", "always", "test", "save"]);
});
it("creates initial values only from declared defaults", () => {
const values = initialPluginFormValues(connectionProvider());
expect(values).toEqual({ port: 1234, tls: false });
expect(values).not.toHaveProperty("host");
});
it("maps provider fields into common, config, and secret storage", () => {
const provider: PluginConnectionProviderContribution = {
type: "connection-provider",
id: "example.ssh",
label: "SSH",
database_type: "ssh",
fields: [
{ key: "display_name", label: "Name", type: "text", binding: "name" },
{ key: "host", label: "Host", type: "text", binding: "host" },
{ key: "port", label: "Port", type: "number", binding: "port", default: 22 },
{ key: "private_key", label: "Private key", type: "password" },
{ key: "keepalive", label: "Keepalive", type: "boolean", default: true },
],
};
const config = buildPluginConnectionConfig("example.plugin", provider, {
display_name: "Production SSH",
host: "example.com",
port: 2222,
private_key: "secret-key",
keepalive: false,
});
expect(config).toMatchObject({
name: "Production SSH",
db_type: "plugin",
host: "example.com",
port: 2222,
plugin_id: "example.plugin",
plugin_connection_provider: "example.ssh",
plugin_connection_type: "ssh",
external_config: { keepalive: false },
connection_secrets: { private_key: "secret-key" },
});
expect(pluginConnectionFormValues(provider, config)).toMatchObject({
display_name: "Production SSH",
host: "example.com",
port: 2222,
private_key: "secret-key",
keepalive: false,
});
});
});
@@ -0,0 +1,302 @@
import type {
ConnectionConfig,
InstalledPlugin,
PluginConnectionAction,
PluginConnectionProviderContribution,
PluginContribution,
PluginContributionLocalization,
PluginContextMenuContribution,
PluginResultViewContribution,
PluginFilesystemProviderContribution,
PluginFormField,
PluginFormFieldLocalization,
PluginFormFieldValue,
PluginManifestLocalization,
PluginWorkbenchContribution,
} from "@/types/database";
import { uuid } from "@/lib/common/utils";
import { clonePluginData } from "./pluginData";
const PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX = "plugin-provider:";
export interface FrontendPluginDefinition {
plugin: InstalledPlugin;
contributions: PluginContribution[];
diagnostics: string[];
}
export interface PluginContributionEntry<T extends PluginContribution> {
plugin: InstalledPlugin;
contribution: T;
}
export class FrontendPluginRegistry {
private readonly definitions: FrontendPluginDefinition[];
constructor(plugins: readonly InstalledPlugin[] = [], locale = "en") {
this.definitions = plugins.map((plugin) => normalizeFrontendPlugin(plugin, locale));
}
listPlugins(): FrontendPluginDefinition[] {
return [...this.definitions];
}
findPlugin(pluginId: string): FrontendPluginDefinition | undefined {
return this.definitions.find((definition) => definition.plugin.manifest.id === pluginId);
}
listConnectionProviders(): PluginContributionEntry<PluginConnectionProviderContribution>[] {
return this.listContributions("connection-provider");
}
listWorkbenches(): PluginContributionEntry<PluginWorkbenchContribution>[] {
return this.listContributions("workbench");
}
listFilesystemProviders(): PluginContributionEntry<PluginFilesystemProviderContribution>[] {
return this.listContributions("filesystem-provider");
}
/** Native context-menu entries declared for a specific menu surface such as `connection`. */
listContextMenuItems(menu: string): PluginContributionEntry<PluginContextMenuContribution>[] {
return this.listContributions("context-menu").filter((entry) => entry.contribution.menu === menu);
}
/** Plugin-rendered query-result views offered from the results toolbar. */
listResultViews(): PluginContributionEntry<PluginResultViewContribution>[] {
return this.listContributions("result-view");
}
findWorkbench(pluginId: string, contributionId: string): PluginContributionEntry<PluginWorkbenchContribution> | undefined {
return this.listWorkbenches().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === contributionId);
}
private listContributions<T extends PluginContribution["type"]>(type: T): Array<PluginContributionEntry<Extract<PluginContribution, { type: T }>>> {
return this.definitions
.filter((definition) => definition.plugin.compatibility.compatible)
.flatMap((definition) => definition.contributions.filter((contribution): contribution is Extract<PluginContribution, { type: T }> => contribution.type === type).map((contribution) => ({ plugin: definition.plugin, contribution })))
.sort((left, right) => `${left.plugin.manifest.name}:${left.contribution.label || left.contribution.id}`.localeCompare(`${right.plugin.manifest.name}:${right.contribution.label || right.contribution.id}`));
}
}
export function createFrontendPluginRegistry(plugins: readonly InstalledPlugin[], locale = "en"): FrontendPluginRegistry {
return new FrontendPluginRegistry(plugins, locale);
}
export function pluginConnectionProviderOptionValue(pluginId: string, providerId: string): string {
return `${PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX}${encodeURIComponent(pluginId)}/${encodeURIComponent(providerId)}`;
}
export function parsePluginConnectionProviderOptionValue(value: string): { pluginId: string; providerId: string } | null {
if (!value.startsWith(PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX)) return null;
const encoded = value.slice(PLUGIN_CONNECTION_PROVIDER_OPTION_PREFIX.length);
const separator = encoded.indexOf("/");
if (separator <= 0 || separator === encoded.length - 1) return null;
try {
const pluginId = decodeURIComponent(encoded.slice(0, separator));
const providerId = decodeURIComponent(encoded.slice(separator + 1));
return pluginId && providerId ? { pluginId, providerId } : null;
} catch {
return null;
}
}
export function pluginConnectionProviderIcon(entry: PluginContributionEntry<PluginConnectionProviderContribution>): string | undefined {
return entry.contribution.icon || entry.plugin.manifest.icon;
}
export function pluginConnectionActionsForDialog(contribution: PluginConnectionProviderContribution, editing: boolean): PluginConnectionAction[] {
const actions: PluginConnectionAction[] = [
...(contribution.actions || []).map((action) => ({ ...action, kind: "custom" as const })),
...((contribution.capabilities || []).includes("test") ? [{ id: "test", kind: "test" as const, variant: "outline" as const }] : []),
editing ? ({ id: "save", kind: "save", variant: "default", close_on_success: true } satisfies PluginConnectionAction) : ({ id: "save-and-connect", kind: "save-and-connect", variant: "default", close_on_success: true } satisfies PluginConnectionAction),
];
return actions.filter((action) => action.when === undefined || action.when === "always" || (action.when === "edit" ? editing : !editing));
}
export function initialPluginFormValues(contribution: PluginConnectionProviderContribution): Record<string, PluginFormFieldValue> {
return Object.fromEntries(contribution.fields.filter((field) => field.default !== undefined).map((field) => [field.key, field.default])) as Record<string, PluginFormFieldValue>;
}
export function pluginConnectionFormValues(contribution: PluginConnectionProviderContribution, config?: ConnectionConfig): Record<string, PluginFormFieldValue> {
const values = initialPluginFormValues(contribution);
if (!config) return values;
const externalConfig = isRecord(config.external_config) ? config.external_config : {};
const secrets = config.connection_secrets || {};
for (const field of contribution.fields) {
const binding = effectiveFieldBinding(field);
const value =
binding === "name"
? config.name
: binding === "host"
? config.host
: binding === "port"
? config.port
: binding === "username"
? config.username
: binding === "password"
? config.password
: binding === "database"
? config.database
: binding === "secret"
? secrets[field.key]
: externalConfig[field.key];
if (isPluginFormFieldValue(value)) values[field.key] = value;
}
return values;
}
export function buildPluginConnectionConfig(pluginId: string, contribution: PluginConnectionProviderContribution, values: Record<string, PluginFormFieldValue>, existing?: ConnectionConfig): ConnectionConfig {
const externalConfig: Record<string, unknown> = isRecord(existing?.external_config) ? clonePluginData(existing.external_config) : {};
const connectionSecrets = { ...existing?.connection_secrets };
const config: ConnectionConfig = {
id: existing?.id || uuid(),
name: existing?.name || contribution.label,
note: existing?.note,
db_type: "plugin",
driver_profile: existing?.driver_profile || "plugin",
driver_label: contribution.label,
host: existing?.host || "",
port: existing?.port || 0,
username: existing?.username || "",
password: existing?.password || "",
database: existing?.database,
external_config: externalConfig,
plugin_id: pluginId,
plugin_connection_provider: contribution.id,
plugin_connection_type: contribution.database_type,
connection_secrets: connectionSecrets,
transport_layers: existing?.transport_layers || [],
connect_timeout_secs: existing?.connect_timeout_secs || 10,
query_timeout_secs: existing?.query_timeout_secs || 60,
idle_timeout_secs: existing?.idle_timeout_secs || 60,
keepalive_interval_secs: existing?.keepalive_interval_secs || 30,
read_only: existing?.read_only || false,
is_production: existing?.is_production || false,
production_databases: existing?.production_databases || [],
};
for (const field of contribution.fields) {
const value = values[field.key] ?? field.default;
const binding = effectiveFieldBinding(field);
if (binding === "config") {
if (value === undefined) delete externalConfig[field.key];
else externalConfig[field.key] = value;
} else if (binding === "secret") {
if (value === undefined || value === "") delete connectionSecrets[field.key];
else connectionSecrets[field.key] = String(value);
} else if (binding === "name") {
config.name = String(value || contribution.label);
} else if (binding === "host") {
config.host = String(value || "");
} else if (binding === "port") {
config.port = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(65535, Math.trunc(value))) : 0;
} else if (binding === "username") {
config.username = String(value || "");
} else if (binding === "password") {
config.password = String(value || "");
} else if (binding === "database") {
config.database = value === undefined || value === "" ? undefined : String(value);
}
}
return config;
}
function effectiveFieldBinding(field: PluginFormField): NonNullable<PluginFormField["binding"]> {
return field.binding || (field.type === "password" ? "secret" : "config");
}
function normalizeFrontendPlugin(plugin: InstalledPlugin, locale: string): FrontendPluginDefinition {
const localization = pluginLocalization(plugin, locale);
const localizedPlugin = localizePluginMetadata(plugin, localization);
const contributions = (localizedPlugin.manifest.contributions || []).map((contribution) => localizeContribution(contribution, localization?.contributions?.[contribution.id], localizedPlugin.manifest.name));
const diagnostics: string[] = [];
if (!plugin.compatibility.compatible) diagnostics.push(...(plugin.compatibility.errors || []));
return { plugin: localizedPlugin, contributions, diagnostics };
}
function pluginLocalization(plugin: InstalledPlugin, locale: string): PluginManifestLocalization | undefined {
const localizations = plugin.manifest.localizations;
if (!localizations || !locale) return undefined;
const normalizedLocale = locale.replace("_", "-").toLowerCase();
const exact = Object.entries(localizations).find(([key]) => key.replace("_", "-").toLowerCase() === normalizedLocale)?.[1];
if (exact) return exact;
const language = normalizedLocale.split("-")[0];
return Object.entries(localizations).find(([key]) => key.replace("_", "-").toLowerCase() === language)?.[1];
}
function localizePluginMetadata(plugin: InstalledPlugin, localization?: PluginManifestLocalization): InstalledPlugin {
return {
...plugin,
manifest: {
...plugin.manifest,
name: localizedRequiredText(plugin.manifest.name, localization?.name),
icon: optionalPluginAssetPath(plugin.manifest.icon),
description: localizedOptionalText(plugin.manifest.description, localization?.description),
},
};
}
function localizeContribution(contribution: PluginContribution, localization: PluginContributionLocalization | undefined, pluginName: string): PluginContribution {
const fallbackLabel = contribution.type === "connection-provider" ? optionalTrimmed(contribution.label) || optionalTrimmed(pluginName) || contribution.id : contribution.label;
const localized = {
...contribution,
label: localizedRequiredText(fallbackLabel, localization?.label),
description: localizedOptionalText(contribution.description, localization?.description),
} as PluginContribution;
if (localized.type === "connection-provider") {
localized.icon = optionalPluginAssetPath(localized.icon);
localized.fields = localized.fields.map((field) => localizeField(field, localization?.fields?.[field.key]));
localized.actions = localized.actions?.map((action) => ({
...action,
label: localizedRequiredText(action.label, localization?.actions?.[action.id]?.label),
description: localizedOptionalText(action.description, localization?.actions?.[action.id]?.description),
}));
} else if (localized.type === "workbench") {
localized.icon = optionalPluginAssetPath(localized.icon);
}
return localized;
}
function localizeField(field: PluginFormField, localization?: PluginFormFieldLocalization): PluginFormField {
if (!localization) return field;
return {
...field,
label: localizedRequiredText(field.label, localization.label),
description: localizedOptionalText(field.description, localization.description),
placeholder: localizedOptionalText(field.placeholder, localization.placeholder),
options: field.options?.map((option) => ({ ...option, label: localizedRequiredText(option.label, localization.options?.[option.value]) })),
};
}
function localizedRequiredText(fallback: string, localized: unknown): string {
return nonEmptyString(localized) ? localized.trim() : fallback;
}
function localizedOptionalText(fallback: string | undefined, localized: unknown): string | undefined {
return typeof localized === "string" ? localized.trim() : fallback;
}
function optionalTrimmed(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function optionalPluginAssetPath(value: unknown): string | undefined {
const path = optionalTrimmed(value);
if (!path || path.startsWith("/") || path.startsWith("\\") || path.includes("\\")) return undefined;
const parts = path.split("/");
return parts.some((part) => !part || part === "." || part === "..") ? undefined : path;
}
function nonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}
function isPluginFormFieldValue(value: unknown): value is PluginFormFieldValue {
return value === undefined || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,4 @@
export interface PluginCenterFocus {
pluginId?: string;
providerId?: string;
}
+103
View File
@@ -0,0 +1,103 @@
import { isProxy, toRaw } from "vue";
export const MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES = 2 * 1024 * 1024;
export function clonePluginData<T>(value: T): T {
const unwrapped = unwrapVueProxies(value);
try {
if (typeof structuredClone === "function") return structuredClone(unwrapped);
return JSON.parse(JSON.stringify(unwrapped)) as T;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Plugin data cannot be cloned: ${message}`);
}
}
export function snapshotPluginWorkbenchContext<T extends object>(value: T): T {
const snapshot = snapshotValue(value, "context", new WeakSet<object>());
if (!snapshot || Array.isArray(snapshot) || typeof snapshot !== "object") {
throw new Error("Plugin workbench context must be an object");
}
const size = new TextEncoder().encode(JSON.stringify(snapshot)).byteLength;
if (size > MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES) {
throw new Error(`Plugin workbench context exceeds ${MAX_PLUGIN_WORKBENCH_CONTEXT_BYTES} bytes`);
}
return snapshot as T;
}
type PluginContextValue = boolean | number | string | null | PluginContextValue[] | { [key: string]: PluginContextValue } | undefined;
function snapshotValue(value: unknown, path: string, ancestors: WeakSet<object>): PluginContextValue {
if (value === undefined) return undefined;
if (value === null || typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number") {
if (Number.isFinite(value)) return value;
throw new Error(`Plugin workbench context contains a non-finite number at ${path}`);
}
if (typeof value !== "object") throw new Error(`Plugin workbench context contains an unsupported value at ${path}`);
const raw = isProxy(value) ? toRaw(value) : value;
if (ancestors.has(raw)) throw new Error(`Plugin workbench context contains a circular reference at ${path}`);
const prototype = Object.getPrototypeOf(raw);
if (!Array.isArray(raw) && prototype !== Object.prototype && prototype !== null) {
throw new Error(`Plugin workbench context requires plain data at ${path}`);
}
ancestors.add(raw);
try {
if (Array.isArray(raw)) {
return raw.map((item, index) => snapshotValue(item, `${path}[${index}]`, ancestors) ?? null);
}
const result: { [key: string]: PluginContextValue } = {};
for (const [key, item] of Object.entries(raw)) {
const snapshot = snapshotValue(item, `${path}.${key}`, ancestors);
if (snapshot !== undefined) result[key] = snapshot;
}
return result;
} finally {
ancestors.delete(raw);
}
}
function unwrapVueProxies<T>(value: T, seen = new WeakMap<object, unknown>()): T {
const rawValue = isProxy(value) ? toRaw(value) : value;
if (!rawValue || typeof rawValue !== "object") return rawValue;
const object = rawValue as object;
const existing = seen.get(object);
if (existing) return existing as T;
if (rawValue instanceof Date || rawValue instanceof RegExp || rawValue instanceof ArrayBuffer || ArrayBuffer.isView(rawValue)) {
return rawValue;
}
if (Array.isArray(rawValue)) {
const result: unknown[] = [];
seen.set(object, result);
rawValue.forEach((item, index) => {
result[index] = unwrapVueProxies(item, seen);
});
return result as T;
}
if (rawValue instanceof Map) {
const result = new Map<unknown, unknown>();
seen.set(object, result);
rawValue.forEach((item, key) => result.set(unwrapVueProxies(key, seen), unwrapVueProxies(item, seen)));
return result as T;
}
if (rawValue instanceof Set) {
const result = new Set<unknown>();
seen.set(object, result);
rawValue.forEach((item) => result.add(unwrapVueProxies(item, seen)));
return result as T;
}
const prototype = Object.getPrototypeOf(rawValue);
if (prototype !== Object.prototype && prototype !== null) return rawValue;
const result = Object.create(prototype) as Record<PropertyKey, unknown>;
seen.set(object, result);
for (const key of Reflect.ownKeys(rawValue)) {
const descriptor = Object.getOwnPropertyDescriptor(rawValue, key);
if (!descriptor || !("value" in descriptor)) continue;
Object.defineProperty(result, key, { ...descriptor, value: unwrapVueProxies(descriptor.value, seen) });
}
return result as T;
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { pluginFilesystemParentUri, pluginFilesystemRootUri, sortPluginFilesystemEntries, uniquePluginFilesystemEntries } from "./pluginFilesystem";
describe("plugin filesystem navigation", () => {
it("derives a default root from the first declared scheme", () => {
expect(pluginFilesystemRootUri({ schemes: ["sftp"] })).toBe("sftp:/");
expect(pluginFilesystemRootUri({ schemes: ["s3"], root_uri: "s3://bucket/" })).toBe("s3://bucket/");
});
it("keeps parent navigation inside the declared root", () => {
expect(pluginFilesystemParentUri("sftp:/home/user/files/", "sftp:/")).toBe("sftp:/home/user/");
expect(pluginFilesystemParentUri("s3://bucket/reports/2026/", "s3://bucket/")).toBe("s3://bucket/reports/");
expect(pluginFilesystemParentUri("s3://bucket/reports/", "s3://bucket/reports/")).toBeUndefined();
});
it("sorts directories before naturally ordered files", () => {
const sorted = sortPluginFilesystemEntries([
{ name: "file10.txt", uri: "sample:/file10.txt", kind: "file" },
{ name: "folder", uri: "sample:/folder/", kind: "directory" },
{ name: "file2.txt", uri: "sample:/file2.txt", kind: "file" },
]);
expect(sorted.map((entry) => entry.name)).toEqual(["folder", "file2.txt", "file10.txt"]);
});
it("removes duplicate entries by URI while preserving the latest metadata", () => {
expect(
uniquePluginFilesystemEntries([
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 1 },
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 2 },
{ name: "other.txt", uri: "s3:/other.txt", kind: "file" },
]),
).toEqual([
{ name: "object.txt", uri: "s3:/object.txt", kind: "file", size: 2 },
{ name: "other.txt", uri: "s3:/other.txt", kind: "file" },
]);
});
});
@@ -0,0 +1,34 @@
import type { PluginFilesystemEntry, PluginFilesystemProviderContribution } from "@/types/database";
export function pluginFilesystemRootUri(provider: Pick<PluginFilesystemProviderContribution, "root_uri" | "schemes">): string {
return provider.root_uri || `${provider.schemes[0]}:/`;
}
export function pluginFilesystemParentUri(uri: string, root: string): string | undefined {
if (!uri || uri === root) return undefined;
const schemeEnd = uri.indexOf(":");
if (schemeEnd < 1) return root;
const minimumEnd = uri.startsWith("//", schemeEnd + 1)
? (() => {
const authorityEnd = uri.indexOf("/", schemeEnd + 3);
return authorityEnd < 0 ? uri.length : authorityEnd + 1;
})()
: schemeEnd + 2;
const normalized = uri.endsWith("/") && uri.length > minimumEnd ? uri.slice(0, -1) : uri;
const separator = normalized.lastIndexOf("/");
const parent = separator < minimumEnd ? uri.slice(0, minimumEnd) : uri.slice(0, separator + 1);
return parent.length < root.length ? root : parent;
}
export function sortPluginFilesystemEntries(entries: readonly PluginFilesystemEntry[]): PluginFilesystemEntry[] {
return [...entries].sort((left, right) => {
const leftDirectory = left.kind === "directory";
const rightDirectory = right.kind === "directory";
if (leftDirectory !== rightDirectory) return leftDirectory ? -1 : 1;
return left.name.localeCompare(right.name, undefined, { numeric: true, sensitivity: "base" });
});
}
export function uniquePluginFilesystemEntries(entries: readonly PluginFilesystemEntry[]): PluginFilesystemEntry[] {
return [...new Map(entries.map((entry) => [entry.uri, entry])).values()];
}
+321
View File
@@ -0,0 +1,321 @@
import { describe, expect, it, vi } from "vitest";
import { reactive, readonly } from "vue";
import { PluginHostBridge, pluginSandboxDocument } from "./pluginHostBridge";
import type { InstalledPlugin, PluginWorkbenchContribution } from "@/types/database";
function plugin(permissions: string[] = []): InstalledPlugin {
return {
manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions, drivers: [], contributions: [] },
compatibility: { compatible: true },
};
}
const workbench: PluginWorkbenchContribution = { type: "workbench", id: "sample.main", label: "Sample" };
describe("PluginHostBridge", () => {
it("binds backend calls to the owning plugin identity", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const invoke = vi.fn().mockResolvedValue({ ok: true });
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke,
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
expect(
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "1", method: "backend.invoke", params: { method: "sample/hello", params: { name: "DBX" } } },
} as MessageEvent),
).toBe(true);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(invoke).toHaveBeenCalledWith("sample", "sample/hello", { name: "DBX" }, undefined);
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "response", id: "1", result: { ok: true } });
});
it("sends the current DBX locale in the init message", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(
plugin(),
workbench,
{ connectionId: "connection" },
() => target,
{
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
},
"zh-CN",
);
bridge.sendInit();
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "init", locale: "zh-CN", context: { connectionId: "connection" } });
});
it("snapshots nested Vue reactive context values before sending them to the plugin", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const context = {
connectionId: "connection",
values: reactive({ path: "/tmp", options: readonly({ recursive: true }) }),
};
const bridge = new PluginHostBridge(plugin(), workbench, context, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.sendInit();
expect(messages[0]).toMatchObject({
type: "init",
context: { connectionId: "connection", values: { path: "/tmp", options: { recursive: true } } },
});
expect((messages[0] as { context: typeof context }).context).not.toBe(context);
});
it("reports unsupported plugin context values", () => {
expect(
() =>
new PluginHostBridge(plugin(), workbench, { value: () => undefined }, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("Plugin workbench context contains an unsupported value");
});
it("rejects circular and oversized plugin contexts", () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
expect(
() =>
new PluginHostBridge(plugin(), workbench, circular, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("circular reference");
expect(
() =>
new PluginHostBridge(plugin(), workbench, { value: "x".repeat(2 * 1024 * 1024) }, () => null, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
}),
).toThrow("exceeds");
});
it("rejects privileged host calls without manifest permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
openWorkbench: vi.fn(),
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "2", method: "host.openWorkbench", params: { contributionId: "sample.other" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "2", error: "Plugin has not declared permission 'host.workbench'" });
});
it("opens only the owning plugin filesystem with explicit permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const openFilesystem = vi.fn();
const bridge = new PluginHostBridge(plugin(["host.filesystem"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
openFilesystem,
});
bridge.handleWindowMessage({
source: target,
data: {
source: "dbx-plugin",
version: 1,
type: "request",
id: "filesystem",
method: "host.openFilesystem",
params: { providerId: "sample.files", context: { connectionId: "connection" } },
},
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(openFilesystem).toHaveBeenCalledWith("sample", "sample.files", { connectionId: "connection" });
expect(messages[0]).toMatchObject({ id: "filesystem", result: null });
});
it("forwards events and binary traffic only with declared permissions", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const sendBinary = vi.fn().mockResolvedValue(undefined);
const bridge = new PluginHostBridge(plugin(["host.events", "host.binary"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary,
readAsset: vi.fn(),
});
bridge.forwardEvent({ pluginId: "sample", method: "sample/progress", params: { value: 50 } });
bridge.forwardBinary({ pluginId: "sample", channel: "pty", dataBase64: "AQI=" });
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "3", method: "backend.sendBinary", params: { channel: "pty", dataBase64: "AQI=" } },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(3));
expect(messages[0]).toMatchObject({ type: "event", method: "sample/progress" });
expect(messages[1]).toMatchObject({ type: "binary", channel: "pty" });
expect(sendBinary).toHaveBeenCalledWith("sample", "pty", "AQI=");
expect(messages[2]).toMatchObject({ type: "response", id: "3", result: null });
});
it("returns delayed responses to the iframe that issued the request", async () => {
const firstMessages: unknown[] = [];
const secondMessages: unknown[] = [];
const first = { postMessage: (message: unknown) => firstMessages.push(message) } as unknown as Window;
const second = { postMessage: (message: unknown) => secondMessages.push(message) } as unknown as Window;
let current = first;
let resolveInvoke: (value: unknown) => void = () => {};
const invoke = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
resolveInvoke = resolve;
}),
);
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => current, {
invoke,
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.handleWindowMessage({
source: first,
data: { source: "dbx-plugin", version: 1, type: "request", id: "4", method: "backend.invoke", params: { method: "sample/slow" } },
} as MessageEvent);
current = second;
resolveInvoke({ ok: true });
await vi.waitFor(() => expect(firstMessages).toHaveLength(1));
expect(firstMessages[0]).toMatchObject({ type: "response", id: "4", result: { ok: true } });
expect(secondMessages).toHaveLength(0);
});
it("pushes context and locale updates without rebuilding the iframe", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, { connectionId: "first" }, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.updateContext({ connectionId: "second", values: { path: "/tmp" } });
bridge.updateLocale("ja");
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "context", context: { connectionId: "second" } });
expect(messages[1]).toMatchObject({ source: "dbx-host", type: "env", locale: "ja" });
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id: "ctx", method: "host.getContext" } } as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(3));
expect(messages[2]).toMatchObject({ type: "response", id: "ctx", result: { connectionId: "second", values: { path: "/tmp" } } });
});
it("accepts zero-copy binary requests and forwards binary frames as raw buffers", async () => {
const messages: unknown[] = [];
const target = {
postMessage: (message: unknown, _origin?: string, _transfer?: Transferable[]) => messages.push(message),
} as unknown as Window;
const sendBinary = vi.fn().mockResolvedValue(undefined);
const bridge = new PluginHostBridge(plugin(["host.binary"]), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary,
readAsset: vi.fn(),
});
bridge.forwardBinary({ pluginId: "sample", channel: "pty", dataBase64: "AQID" });
expect(messages[0]).toMatchObject({ type: "binary", channel: "pty" });
expect((messages[0] as { data: ArrayBuffer }).data).toBeInstanceOf(ArrayBuffer);
expect(Array.from(new Uint8Array((messages[0] as { data: ArrayBuffer }).data))).toEqual([1, 2, 3]);
const bytes = new Uint8Array([4, 5, 6]).buffer;
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "bin", method: "backend.sendBinary", params: { channel: "pty" }, data: bytes },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(2));
expect(sendBinary).toHaveBeenCalledWith("sample", "pty", "BAUG");
expect(messages[1]).toMatchObject({ type: "response", id: "bin", result: null });
});
it("rejects binary transfer requests without the host.binary permission", async () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, {
invoke: vi.fn(),
notify: vi.fn(),
sendBinary: vi.fn(),
readAsset: vi.fn(),
});
bridge.handleWindowMessage({
source: target,
data: { source: "dbx-plugin", version: 1, type: "request", id: "denied", method: "backend.sendBinary", params: { channel: "pty" }, data: new Uint8Array([1]).buffer },
} as MessageEvent);
await vi.waitFor(() => expect(messages).toHaveLength(1));
expect(messages[0]).toMatchObject({ id: "denied", error: "Plugin has not declared permission 'host.binary'" });
});
it("injects the SDK, the official UI kit, and a restrictive sandbox CSP", () => {
const document = pluginSandboxDocument("<html><head></head><body>Hello</body></html>");
expect(document).toContain("window.dbxPlugin");
expect(document).toContain("get locale() { return locale; }");
expect(document).toContain("openFilesystem");
expect(document).toContain("connect-src 'none'");
expect(document).toContain(".dbx-btn");
expect(document).toContain("var(--color-background");
});
it("opens connect-src only for declared host.network origins", () => {
const allowed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events", "host.network:https://api.vendor.com", "host.network:https://metrics.vendor.com:8443", "host.network:http://insecure.vendor.com", "host.network:https://evil.vendor.com/path"]);
expect(allowed).toContain("connect-src https://api.vendor.com https://metrics.vendor.com:8443;");
expect(allowed).not.toContain("insecure.vendor.com");
expect(allowed).not.toContain("evil.vendor.com");
const closed = pluginSandboxDocument("<html><head></head><body></body></html>", ["host.events"]);
expect(closed).toContain("connect-src 'none';");
});
it("sends the theme in init and pushes theme updates through env messages", () => {
const messages: unknown[] = [];
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
const theme = { appearance: "dark" as const, tokens: { "--color-background": "#09090b" } };
const bridge = new PluginHostBridge(plugin(), workbench, {}, () => target, { invoke: vi.fn(), notify: vi.fn(), sendBinary: vi.fn(), readAsset: vi.fn() }, "en", theme);
bridge.sendInit();
expect(messages[0]).toMatchObject({ type: "init", theme: { appearance: "dark", tokens: { "--color-background": "#09090b" } } });
bridge.updateTheme({ appearance: "light", tokens: {} });
expect(messages[1]).toMatchObject({ type: "env", locale: "en", theme: { appearance: "light" } });
});
});
@@ -0,0 +1,457 @@
import type { InstalledPlugin, PluginBinaryEvent, PluginEvent, PluginUiAssetPayload, PluginWorkbenchContribution } from "@/types/database";
import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
const HOST_MESSAGE_SOURCE = "dbx-host";
const BRIDGE_VERSION = 1;
const MAX_BRIDGE_PAYLOAD_BYTES = 2 * 1024 * 1024;
const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024;
export interface PluginBridgeTheme {
appearance: "light" | "dark";
/** Resolved DBX design tokens (`--color-*`, `--radius-*`, ...) for the current theme. */
tokens: Record<string, string>;
}
export interface PluginWorkbenchContext {
connectionId?: string;
database?: string;
schema?: string;
values?: Record<string, unknown>;
[key: string]: unknown;
}
export interface PluginHostBridgeApi {
invoke<T = unknown>(pluginId: string, method: string, params?: unknown, timeoutMs?: number): Promise<T>;
notify(pluginId: string, method: string, params?: unknown): Promise<void>;
sendBinary(pluginId: string, channel: string, dataBase64: string): Promise<void>;
readAsset(pluginId: string, path: string): Promise<PluginUiAssetPayload>;
openWorkbench?(pluginId: string, contributionId: string, context?: PluginWorkbenchContext): Promise<void> | void;
openFilesystem?(pluginId: string, providerId: string, context?: PluginWorkbenchContext): Promise<void> | void;
}
interface PluginRequestMessage {
source: typeof PLUGIN_MESSAGE_SOURCE;
version: typeof BRIDGE_VERSION;
type: "request";
id: string;
method: string;
params?: unknown;
/** Optional zero-copy binary payload transferred with the request. */
data?: ArrayBuffer;
}
export class PluginHostBridge {
private context: PluginWorkbenchContext;
private locale: string;
private theme?: PluginBridgeTheme;
constructor(
private readonly plugin: InstalledPlugin,
private readonly workbench: PluginWorkbenchContribution,
context: PluginWorkbenchContext,
private readonly targetWindow: () => Window | null,
private readonly api: PluginHostBridgeApi,
locale = "en",
theme?: PluginBridgeTheme,
) {
this.context = snapshotPluginWorkbenchContext(context);
this.locale = locale;
this.theme = theme ? clonePluginData(theme) : undefined;
}
handleWindowMessage(event: MessageEvent): boolean {
const target = this.targetWindow();
if (!target || event.source !== target || !isRecord(event.data)) return false;
if (event.data.source !== PLUGIN_MESSAGE_SOURCE || event.data.version !== BRIDGE_VERSION) return false;
if (event.data.type === "ready") {
this.sendInit();
return true;
}
if (event.data.type !== "request" || !validRequestMessage(event.data)) return false;
void this.handleRequest(event.data, target);
return true;
}
sendInit(): void {
this.post({
source: HOST_MESSAGE_SOURCE,
version: BRIDGE_VERSION,
type: "init",
pluginId: this.plugin.manifest.id,
contributionId: this.workbench.id,
locale: this.locale,
theme: this.theme ? clonePluginData(this.theme) : undefined,
permissions: [...(this.plugin.manifest.permissions || [])],
context: snapshotPluginWorkbenchContext(this.context),
});
}
/**
* Push a new workbench context into the already-loaded plugin UI instead of
* rebuilding the iframe. Identity changes (plugin/contribution) still require
* a full reload; context-only changes must not lose plugin state.
*/
updateContext(context: PluginWorkbenchContext): void {
this.context = snapshotPluginWorkbenchContext(context);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "context", context: snapshotPluginWorkbenchContext(this.context) });
}
/** Notify the plugin UI about a locale change without a reload. */
updateLocale(locale: string): void {
this.locale = locale;
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale });
}
/** Push resolved theme tokens so the plugin UI can follow DBX light/dark and palette changes. */
updateTheme(theme: PluginBridgeTheme): void {
this.theme = clonePluginData(theme);
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "env", locale: this.locale, theme: this.theme });
}
forwardEvent(event: PluginEvent): void {
if (event.pluginId !== this.plugin.manifest.id || !this.hasPermission("host.events")) return;
this.post({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "event", method: event.method, params: event.params });
}
forwardBinary(event: PluginBinaryEvent): void {
if (event.pluginId !== this.plugin.manifest.id || !this.hasPermission("host.binary")) return;
const target = this.targetWindow();
if (!target) return;
const buffer = base64ToBytes(event.dataBase64);
target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "binary", channel: event.channel, data: buffer }, "*", [buffer]);
}
private async handleRequest(request: PluginRequestMessage, target: Window): Promise<void> {
try {
enforcePayloadLimit(request.params);
const result = await this.dispatch(request.method, request.params, request.data);
this.respond(target, request.id, { result: result ?? null });
} catch (error) {
this.respond(target, request.id, { error: error instanceof Error ? error.message : String(error) });
}
}
private async dispatch(method: string, params: unknown, binary?: ArrayBuffer): Promise<unknown> {
if (method === "host.getContext") return snapshotPluginWorkbenchContext(this.context);
if (method === "backend.invoke") {
const input = requireRecord(params, "backend.invoke params");
const backendMethod = requireProtocolName(input.method, "backend method");
const timeoutMs = input.timeoutMs === undefined ? undefined : requireTimeout(input.timeoutMs);
return this.api.invoke(this.plugin.manifest.id, backendMethod, input.params ?? null, timeoutMs);
}
if (method === "backend.notify") {
const input = requireRecord(params, "backend.notify params");
await this.api.notify(this.plugin.manifest.id, requireProtocolName(input.method, "backend method"), input.params ?? null);
return null;
}
if (method === "backend.sendBinary") {
this.requirePermission("host.binary");
const input = requireRecord(params, "backend.sendBinary params");
const channel = requireProtocolName(input.channel, "binary channel");
if (binary instanceof ArrayBuffer) {
if (binary.byteLength > MAX_BRIDGE_BINARY_BYTES) throw new Error("Plugin binary payload exceeds 8 MiB; chunk the transfer");
await this.api.sendBinary(this.plugin.manifest.id, channel, bytesToBase64(new Uint8Array(binary)));
return null;
}
await this.api.sendBinary(this.plugin.manifest.id, channel, requireBase64(input.dataBase64));
return null;
}
if (method === "ui.readAsset") {
const input = requireRecord(params, "ui.readAsset params");
return this.api.readAsset(this.plugin.manifest.id, requireSafeAssetPath(input.path));
}
if (method === "host.openWorkbench") {
this.requirePermission("host.workbench");
if (!this.api.openWorkbench) throw new Error("Host workbench navigation is unavailable");
const input = requireRecord(params, "host.openWorkbench params");
await this.api.openWorkbench(this.plugin.manifest.id, requireProtocolName(input.contributionId, "workbench contribution"), isRecord(input.context) ? input.context : undefined);
return null;
}
if (method === "host.openFilesystem") {
this.requirePermission("host.filesystem");
if (!this.api.openFilesystem) throw new Error("Host filesystem navigation is unavailable");
const input = requireRecord(params, "host.openFilesystem params");
await this.api.openFilesystem(this.plugin.manifest.id, requireProtocolName(input.providerId, "filesystem provider"), isRecord(input.context) ? input.context : undefined);
return null;
}
throw new Error(`Unsupported plugin host method '${method}'`);
}
private requirePermission(permission: string): void {
if (!this.hasPermission(permission)) throw new Error(`Plugin has not declared permission '${permission}'`);
}
private hasPermission(permission: string): boolean {
return (this.plugin.manifest.permissions || []).includes(permission);
}
private respond(target: Window, id: string, payload: { result?: unknown; error?: string }): void {
target.postMessage({ source: HOST_MESSAGE_SOURCE, version: BRIDGE_VERSION, type: "response", id, ...payload }, "*");
}
private post(message: Record<string, unknown>): void {
this.targetWindow()?.postMessage(message, "*");
}
}
/**
* Parse `host.network:<origin>` permission entries into CSP connect-src
* origins. Must stay aligned with `parse_host_network_permission` in
* crates/dbx-core/src/plugins/manifest.rs.
*/
export function pluginNetworkOrigins(permissions: readonly string[] | undefined): string[] {
const origins = new Set<string>();
for (const permission of permissions || []) {
if (!permission.startsWith("host.network:")) continue;
const origin = permission.slice("host.network:".length);
if (!/^https:\/\/[A-Za-z0-9._-]+(?::[0-9]+)?$/.test(origin)) continue;
origins.add(origin);
if (origins.size >= 8) break;
}
return [...origins];
}
export function pluginSandboxDocument(html: string, permissions?: readonly string[]): string {
const networkOrigins = pluginNetworkOrigins(permissions);
const connectSrc = networkOrigins.length > 0 ? `connect-src ${networkOrigins.join(" ")};` : "connect-src 'none';";
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' blob:; style-src 'unsafe-inline' blob:; img-src data: blob:; font-src data: blob:; ${connectSrc} media-src data: blob:;">`;
const sdk = `<script>${pluginSdkSource()}</script>`;
const uiKit = `<style>${pluginUiKitCss()}</style>`;
const injection = `${csp}${uiKit}${sdk}`;
if (/<head(?:\s[^>]*)?>/i.test(html)) return html.replace(/<head(?:\s[^>]*)?>/i, (head) => `${head}${injection}`);
return `<!doctype html><html><head>${injection}</head><body>${html}</body></html>`;
}
/**
* Minimal official component kit for plugin workbenches. Every class is built
* on the DBX design tokens the host pushes through the bridge, so plugin UI
* follows light/dark and palette changes without any plugin-side logic.
*/
export function pluginUiKitCss(): string {
return `
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif);
font-size: 13px;
line-height: 1.5;
color: var(--color-foreground, #18181b);
background: var(--color-background, #ffffff);
}
.dbx-card {
border: 1px solid var(--color-border, #e4e4e7);
border-radius: var(--radius-lg, 10px);
background: var(--color-card, #ffffff);
padding: 14px 16px;
}
.dbx-section-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-muted-foreground, #71717a);
margin: 0 0 10px;
}
.dbx-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
padding: 0 12px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--color-border, #d4d4d8);
background: var(--color-background, #ffffff);
color: var(--color-foreground, #18181b);
font-size: 13px;
cursor: pointer;
}
.dbx-btn:hover { background: var(--color-muted, #f4f4f5); }
.dbx-btn--primary { background: var(--color-primary, #2563eb); border-color: var(--color-primary, #2563eb); color: var(--color-primary-foreground, #ffffff); }
.dbx-btn--primary:hover { background: var(--color-primary, #2563eb); opacity: 0.9; }
.dbx-btn--danger { background: var(--color-destructive, #dc2626); border-color: var(--color-destructive, #dc2626); color: var(--color-destructive-foreground, #ffffff); }
.dbx-btn--ghost { border-color: transparent; background: transparent; }
.dbx-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.dbx-label { display: inline-flex; font-size: 12px; font-weight: 500; color: var(--color-foreground, #18181b); }
.dbx-input, .dbx-select, .dbx-textarea {
width: 100%;
height: 30px;
padding: 0 10px;
border-radius: var(--radius-md, 8px);
border: 1px solid var(--color-input, #d4d4d8);
background: var(--color-background, #ffffff);
color: var(--color-foreground, #18181b);
font-size: 13px;
font-family: inherit;
}
.dbx-textarea { height: auto; min-height: 64px; padding: 6px 10px; resize: vertical; }
.dbx-input:focus, .dbx-select:focus, .dbx-textarea:focus { outline: 2px solid var(--color-ring, #93c5fd); outline-offset: 1px; border-color: var(--color-ring, #93c5fd); }
.dbx-hint { font-size: 12px; color: var(--color-muted-foreground, #71717a); }
.dbx-row { display: grid; grid-template-columns: minmax(96px, auto) minmax(0, 1fr); gap: 8px 12px; align-items: center; margin-bottom: 10px; }
.dbx-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.dbx-table th { text-align: left; font-weight: 600; color: var(--color-muted-foreground, #71717a); border-bottom: 1px solid var(--color-border, #e4e4e7); padding: 6px 8px; }
.dbx-table td { border-bottom: 1px solid var(--color-border, #e4e4e7); padding: 6px 8px; }
.dbx-badge { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px; background: var(--color-primary-alpha, rgba(37, 99, 235, 0.12)); color: var(--color-primary, #2563eb); font-size: 11px; font-weight: 500; }
.dbx-link { color: var(--color-primary, #2563eb); text-decoration: none; cursor: pointer; }
.dbx-link:hover { text-decoration: underline; }
`.trim();
}
function pluginSdkSource(): string {
return `(() => {
const pending = new Map();
const listeners = { event: new Set(), binary: new Set(), init: new Set(), context: new Set() };
let sequence = 0;
let context;
let locale = 'en';
let theme;
let resolveReady;
const applyTheme = (value) => {
if (!value || typeof value !== 'object') return;
theme = value;
const root = document.documentElement;
root.dataset.dbxTheme = value.appearance === 'dark' ? 'dark' : 'light';
const tokens = value.tokens && typeof value.tokens === 'object' ? value.tokens : {};
for (const [name, tokenValue] of Object.entries(tokens)) {
if (/^--[a-z0-9-]+$/i.test(name) && typeof tokenValue === 'string') root.style.setProperty(name, tokenValue);
}
};
const ready = new Promise((resolve) => { resolveReady = resolve; });
const request = (method, params, options = {}) => new Promise((resolve, reject) => {
const id = String(++sequence);
pending.set(id, { resolve, reject });
const message = { source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'request', id, method, params };
if (options.transfer) {
message.data = options.transfer;
parent.postMessage(message, '*', [options.transfer]);
} else {
parent.postMessage(message, '*');
}
});
const decode = (value) => Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
const encode = (value) => {
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
};
window.dbxPlugin = Object.freeze({
ready,
get context() { return context; },
get locale() { return locale; },
get theme() { return theme; },
request,
invoke: (method, params, options = {}) => request('backend.invoke', { method, params, timeoutMs: options.timeoutMs }),
notify: (method, params) => request('backend.notify', { method, params }),
sendBinary: (channel, data) => {
if (typeof data === 'string') return request('backend.sendBinary', { channel, dataBase64: data });
const bytes = data instanceof ArrayBuffer ? data : (data instanceof Uint8Array ? data.buffer : new Uint8Array(data).buffer);
return request('backend.sendBinary', { channel }, { transfer: bytes });
},
readAsset: (path) => request('ui.readAsset', { path }),
readAssetUrl: async (path) => {
const asset = await request('ui.readAsset', { path });
return URL.createObjectURL(new Blob([decode(asset.dataBase64)], { type: asset.contentType }));
},
openWorkbench: (contributionId, childContext) => request('host.openWorkbench', { contributionId, context: childContext }),
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
onEvent: (listener) => { listeners.event.add(listener); return () => listeners.event.delete(listener); },
onBinary: (listener) => { listeners.binary.add(listener); return () => listeners.binary.delete(listener); },
onContext: (listener) => { listeners.context.add(listener); return () => listeners.context.delete(listener); },
onInit: (listener) => { listeners.init.add(listener); if (context !== undefined) listener(context); return () => listeners.init.delete(listener); },
decodeBase64: decode,
encodeBase64: encode,
});
addEventListener('message', (event) => {
if (event.source !== parent || !event.data || event.data.source !== '${HOST_MESSAGE_SOURCE}' || event.data.version !== ${BRIDGE_VERSION}) return;
const message = event.data;
if (message.type === 'response') {
const handler = pending.get(message.id);
if (!handler) return;
pending.delete(message.id);
if (message.error) handler.reject(new Error(message.error)); else handler.resolve(message.result);
} else if (message.type === 'init') {
context = message.context;
locale = typeof message.locale === 'string' ? message.locale : 'en';
applyTheme(message.theme);
resolveReady(context);
listeners.init.forEach((listener) => listener(context));
dispatchEvent(new CustomEvent('dbx-plugin-init', { detail: message }));
} else if (message.type === 'context') {
context = message.context;
listeners.context.forEach((listener) => listener(context));
dispatchEvent(new CustomEvent('dbx-plugin-context', { detail: context }));
} else if (message.type === 'env') {
if (typeof message.locale === 'string') locale = message.locale;
if (message.theme) applyTheme(message.theme);
listeners.event.forEach((listener) => listener(message));
dispatchEvent(new CustomEvent('dbx-plugin-env', { detail: message }));
} else if (message.type === 'event') {
listeners.event.forEach((listener) => listener(message));
dispatchEvent(new CustomEvent('dbx-plugin-event', { detail: message }));
} else if (message.type === 'binary') {
const payload = { channel: message.channel, data: message.data ? new Uint8Array(message.data) : new Uint8Array(0) };
listeners.binary.forEach((listener) => listener(payload));
dispatchEvent(new CustomEvent('dbx-plugin-binary', { detail: payload }));
}
});
parent.postMessage({ source: '${PLUGIN_MESSAGE_SOURCE}', version: ${BRIDGE_VERSION}, type: 'ready' }, '*');
})();`;
}
function validRequestMessage(value: Record<string, unknown>): value is Record<string, unknown> & PluginRequestMessage {
return typeof value.id === "string" && value.id.length > 0 && value.id.length <= 128 && typeof value.method === "string" && value.method.length > 0 && value.method.length <= 128;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (!isRecord(value)) throw new Error(`${label} must be an object`);
return value;
}
function requireProtocolName(value: unknown, label: string): string {
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(value)) throw new Error(`${label} is invalid`);
return value;
}
function requireTimeout(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number");
return Math.min(120_000, Math.max(1, Math.round(value)));
}
function base64ToBytes(value: string): ArrayBuffer {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
return bytes.buffer;
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
function requireBase64(value: unknown): string {
if (typeof value !== "string" || value.length > MAX_BRIDGE_PAYLOAD_BYTES * 2 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) throw new Error("Binary payload must be base64");
return value;
}
function requireSafeAssetPath(value: unknown): string {
if (typeof value !== "string" || !value || value.startsWith("/") || value.split("/").some((part) => !part || part === "." || part === "..")) throw new Error("Plugin asset path is invalid");
return value;
}
function enforcePayloadLimit(value: unknown): void {
if (value === undefined) return;
const bytes = new TextEncoder().encode(JSON.stringify(value)).byteLength;
if (bytes > MAX_BRIDGE_PAYLOAD_BYTES) throw new Error("Plugin bridge request is too large");
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import { buildMarketplacePluginListings, filterMarketplacePluginListings, selectMarketplaceArtifact } from "./pluginMarketplace";
import type { InstalledPlugin, PluginRepositoryCatalogResult } from "@/types/database";
const result: PluginRepositoryCatalogResult = {
repository: { id: "dbx-official", name: "DBX Marketplace", kind: "official", enabled: true, managed: true },
target: "darwin-arm64",
catalog: {
catalogVersion: 1,
repository: { id: "dbx-official", name: "DBX Marketplace" },
plugins: [
{
id: "example.hello",
name: "Hello",
description: "Greets the user",
publisher: "DBX",
verified: true,
tags: ["sample"],
permissions: [],
latestVersion: "1.1.0",
versions: [
{
version: "1.1.0",
artifacts: [{ target: "darwin-arm64", url: "https://plugins.example.com/hello.dbxp", sha256: "a".repeat(64), signingKeyId: "dbx.release" }],
},
],
localizations: { "zh-CN": { name: "你好工作台", description: "用于验证插件工作台" } },
},
],
},
};
function installed(version: string): InstalledPlugin {
return {
manifest: {
manifest_version: 1,
id: "example.hello",
name: "Hello",
version,
publisher: "DBX",
description: "",
engines: { dbx: "", host_api: "" },
permissions: [],
entrypoints: {},
contributions: [],
drivers: [],
protocol_version: 1,
},
compatibility: { compatible: true, errors: [], warnings: [], target: "darwin-arm64" },
};
}
describe("plugin marketplace listings", () => {
it("localizes, detects updates, and filters by repository", () => {
const listings = buildMarketplacePluginListings([result], [installed("1.0.0")], "zh-CN");
expect(listings[0]).toMatchObject({ name: "你好工作台", status: "update", target: "darwin-arm64" });
expect(filterMarketplacePluginListings(listings, "验证", "dbx-official")).toHaveLength(1);
});
it("marks a plugin unsupported when the current target has no artifact", () => {
const unsupported = structuredClone(result);
unsupported.target = "linux-x64";
expect(buildMarketplacePluginListings([unsupported], [], "en")[0].status).toBe("unsupported");
});
it("uses a universal artifact when the current target has no exact artifact", () => {
const universal = structuredClone(result);
universal.target = "linux-x64";
universal.catalog!.plugins[0].versions[0].artifacts = [{ target: "universal", url: "https://plugins.example.com/hello-universal.dbxp", sha256: "b".repeat(64), signingKeyId: "dbx.release" }];
expect(buildMarketplacePluginListings([universal], [], "en")[0]).toMatchObject({ status: "install", artifact: { target: "universal" } });
});
it("prefers an exact artifact over the universal fallback", () => {
const artifacts = [
{ target: "universal", url: "https://plugins.example.com/hello-universal.dbxp", sha256: "a".repeat(64), signingKeyId: "dbx.release" },
{ target: "darwin-arm64", url: "https://plugins.example.com/hello-darwin.dbxp", sha256: "b".repeat(64), signingKeyId: "dbx.release" },
];
expect(selectMarketplaceArtifact(artifacts, "darwin-arm64")?.target).toBe("darwin-arm64");
});
});
@@ -0,0 +1,90 @@
import type { InstalledPlugin, PluginMarketplaceArtifact, PluginMarketplacePlugin, PluginRepository, PluginRepositoryCatalogResult } from "@/types/database";
export type MarketplacePluginStatus = "install" | "installed" | "update" | "unsupported";
export const UNIVERSAL_PLUGIN_TARGET = "universal";
export interface MarketplacePluginListing {
key: string;
repository: PluginRepository;
plugin: PluginMarketplacePlugin;
name: string;
description: string;
target: string;
artifact?: PluginMarketplaceArtifact;
installed?: InstalledPlugin;
verified: boolean;
status: MarketplacePluginStatus;
}
export function buildMarketplacePluginListings(results: readonly PluginRepositoryCatalogResult[], installedPlugins: readonly InstalledPlugin[], locale: string): MarketplacePluginListing[] {
const installedById = new Map(installedPlugins.map((plugin) => [plugin.manifest.id, plugin]));
return results
.flatMap((result) =>
(result.catalog?.plugins || []).map((plugin) => {
const localized = marketplacePluginLocalization(plugin, locale);
const latestVersion = plugin.versions.find((version) => version.version === plugin.latestVersion);
const artifact = latestVersion ? selectMarketplaceArtifact(latestVersion.artifacts, result.target) : undefined;
const installed = installedById.get(plugin.id);
const status: MarketplacePluginStatus = !artifact ? "unsupported" : !installed ? "install" : compareVersions(plugin.latestVersion, installed.manifest.version || "0.0.0") > 0 ? "update" : "installed";
return {
key: `${result.repository.id}:${plugin.id}`,
repository: result.repository,
plugin,
name: localized.name,
description: localized.description,
target: result.target,
artifact,
installed,
verified: plugin.verified && listingRepositoryCanVerify(result.repository),
status,
};
}),
)
.sort((left, right) => left.name.localeCompare(right.name));
}
export function selectMarketplaceArtifact(artifacts: readonly PluginMarketplaceArtifact[], target: string): PluginMarketplaceArtifact | undefined {
return artifacts.find((candidate) => candidate.target === target) || artifacts.find((candidate) => candidate.target === UNIVERSAL_PLUGIN_TARGET);
}
function listingRepositoryCanVerify(repository: PluginRepository): boolean {
return repository.kind === "official" || repository.kind === "enterprise";
}
export function filterMarketplacePluginListings(listings: readonly MarketplacePluginListing[], query: string, repositoryId: string): MarketplacePluginListing[] {
const normalizedQuery = query.trim().toLocaleLowerCase();
return listings.filter((listing) => {
if (repositoryId !== "all" && listing.repository.id !== repositoryId) return false;
if (!normalizedQuery) return true;
return [listing.plugin.id, listing.name, listing.description, listing.plugin.publisher, listing.repository.name, ...listing.plugin.tags].join("\n").toLocaleLowerCase().includes(normalizedQuery);
});
}
function marketplacePluginLocalization(plugin: PluginMarketplacePlugin, locale: string): { name: string; description: string } {
const normalizedLocale = locale.replace("_", "-").toLowerCase();
const entries = Object.entries(plugin.localizations || {});
const localization = entries.find(([key]) => key.replace("_", "-").toLowerCase() === normalizedLocale)?.[1] || entries.find(([key]) => key.replace("_", "-").toLowerCase() === normalizedLocale.split("-")[0])?.[1];
return {
name: localization?.name?.trim() || plugin.name,
description: localization?.description?.trim() || plugin.description,
};
}
function compareVersions(left: string, right: string): number {
const leftParts = parseVersion(left);
const rightParts = parseVersion(right);
if (!leftParts || !rightParts) return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" });
const leftNumbers = leftParts.slice(0, 3) as [number, number, number];
const rightNumbers = rightParts.slice(0, 3) as [number, number, number];
for (let index = 0; index < leftNumbers.length; index += 1) {
if (leftNumbers[index] !== rightNumbers[index]) return leftNumbers[index] - rightNumbers[index];
}
return leftParts[3].localeCompare(rightParts[3]);
}
function parseVersion(version: string): [number, number, number, string] | null {
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([^+]+))?/.exec(version);
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || "~"];
}
@@ -6,8 +6,8 @@ const settingsDialogSource = readFileSync(new URL("../../../components/editor/Ed
describe("settings page navigation", () => {
it("keeps query, settings, and driver-manager surfaces mutually exclusive", () => {
expect(appSource).toContain('type MainContentSurface = "query" | "settings" | "driverStore";');
expect(appSource).toMatch(/function activateMainContentSurface\(surface: MainContentSurface\) \{\s*settingsStore\.settingsPageActive = surface === "settings";\s*driverStoreActive\.value = surface === "driverStore";/);
expect(appSource).toContain('type MainContentSurface = "query" | "settings" | "driverStore" | "pluginCenter";');
expect(appSource).toMatch(/function activateMainContentSurface\(surface: MainContentSurface\) \{\s*settingsStore\.settingsPageActive = surface === "settings";\s*driverStoreActive\.value = surface === "driverStore";\s*pluginCenterActive\.value = surface === "pluginCenter";/);
expect(appSource).toMatch(/function activateSettingsPage\(\) \{\s*settingsPageTabOpen\.value = true;\s*activateMainContentSurface\("settings"\);/);
expect(appSource).toMatch(/function activateQuerySurface\(\) \{\s*activateMainContentSurface\("query"\);/);
expect(appSource).toMatch(/driverStoreTabOpen\.value = true;\s*activateMainContentSurface\("driverStore"\);/);
@@ -48,7 +48,7 @@ export interface SettingsSearchRoute {
export type Translate = (key: string) => string;
type ToolbarVisibilityItemKey = "dataTransfer" | "driverManager" | "sqlFile" | "schemaDiff" | "dataCompare" | "checkUpdates" | "sqlLibrary" | "sqlFileTree" | "history" | "ai" | "theme" | "github";
type ToolbarVisibilityItemKey = "dataTransfer" | "driverManager" | "pluginCenter" | "sqlFile" | "schemaDiff" | "dataCompare" | "checkUpdates" | "sqlLibrary" | "sqlFileTree" | "history" | "ai" | "theme" | "github";
export type ToolbarVisibilityItem = { key: ToolbarVisibilityItemKey; titleKey: string; title?: never } | { key: ToolbarVisibilityItemKey; title: string; titleKey?: never };
@@ -60,6 +60,7 @@ export type ToolbarVisibilityItem = { key: ToolbarVisibilityItemKey; titleKey: s
export const TOOLBAR_VISIBILITY_ITEMS: readonly ToolbarVisibilityItem[] = [
{ key: "dataTransfer", titleKey: "transfer.dataTransfer" },
{ key: "driverManager", titleKey: "toolbar.driverManager" },
{ key: "pluginCenter", titleKey: "toolbar.pluginCenter" },
{ key: "sqlFile", titleKey: "sqlFile.title" },
{ key: "schemaDiff", titleKey: "diff.title" },
{ key: "dataCompare", titleKey: "dataCompare.title" },
+10 -4
View File
@@ -3868,6 +3868,8 @@ export const useConnectionStore = defineStore("connection", () => {
await loadMqttTopics(connectionId);
} else if (config.db_type === "nacos") {
await loadNacosNamespaces(connectionId, { force: true });
} else if (config.db_type === "plugin") {
return;
} else {
await loadDatabases(connectionId, { force: true });
}
@@ -3964,8 +3966,10 @@ export const useConnectionStore = defineStore("connection", () => {
await ensureLocalConnectionAttemptActiveAfterConnectResult(config.id, localAttempt, id);
activeConnectionId.value = id;
connectedIds.value.add(id);
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
if (config.db_type !== "plugin") {
void refreshConnectedDatabaseInfo(id, { ...config, id });
await refreshConnectionIdentifierQuote(id, { ...config, id });
}
if (id !== config.id) markSuccessfulLocalConnectionAttempt(config.id, localAttempt);
markSuccessfulLocalConnectionAttempt(id, localAttempt);
markConnectionHealthChecked(id);
@@ -4221,8 +4225,10 @@ export const useConnectionStore = defineStore("connection", () => {
await syncMongoLegacyDriverFallback(connectionId, config);
await ensureLocalConnectionAttemptActiveAfterConnectResult(connectionId, localAttempt, id);
connectedIds.value.add(connectionId);
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
if (config.db_type !== "plugin") {
void refreshConnectedDatabaseInfo(connectionId, config);
await refreshConnectionIdentifierQuote(connectionId, config);
}
markSuccessfulLocalConnectionAttempt(connectionId, localAttempt);
markConnectionHealthChecked(connectionId);
clearConnectionError(connectionId);
+101 -2
View File
@@ -4,8 +4,8 @@ import { isRedisMonitorCommand, startRedisMonitor } from "@/lib/redis/redisMonit
import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
@@ -74,6 +74,8 @@ import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { batchSqlRecoverySql, batchSqlRecoveryState, mergeBatchQueryResults, offsetBatchQueryResultIndexes, prepareBatchSqlRecovery, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
import { decodeQueryResultArchive, encodeQueryResultArchive, type DecodedQueryResultArchive } from "@/lib/query/queryResultArchive";
import * as api from "@/lib/backend/api";
import { createFrontendPluginRegistry } from "@/lib/plugins/frontendPlugin";
import { snapshotPluginWorkbenchContext } from "@/lib/plugins/pluginData";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useSavedSqlStore } from "@/stores/savedSqlStore";
@@ -91,7 +93,7 @@ import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeS
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import type { SavedSqlFile } from "@/types/database";
import i18n from "@/i18n";
import i18n, { currentLocale } from "@/i18n";
import { translateBackendError } from "@/i18n/backend-errors";
import type { SqlExecutionTargetContext } from "@/lib/database/sqlExecutionTargetRegistry";
import type { DriverProfileWorkspaceScope } from "@/lib/database/driverProfileExtensions";
@@ -3145,6 +3147,100 @@ export const useQueryStore = defineStore("query", () => {
tab.nacosTargetKeyword = undefined;
}
function openPluginWorkbench(pluginId: string, contributionId: string, options: { title?: string; connectionId?: string; database?: string; context?: Record<string, unknown>; forceNew?: boolean } = {}) {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-workbench" && tab.pluginWorkbench?.pluginId === pluginId && tab.pluginWorkbench?.contributionId === contributionId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.context) existing.pluginWorkbench = { ...existing.pluginWorkbench!, context: snapshotPluginWorkbenchContext(options.context) };
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const tab: QueryTab = {
id,
title: options.title || contributionId,
connectionId: options.connectionId || "",
database: options.database || "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-workbench",
pluginWorkbench: {
pluginId,
contributionId,
context: options.context ? snapshotPluginWorkbenchContext(options.context) : undefined,
},
};
return registerOpenTab(tab);
}
function openPluginFilesystem(pluginId: string, providerId: string, options: { title?: string; connectionId?: string; rootUri?: string; currentUri?: string; forceNew?: boolean } = {}) {
if (!options.forceNew) {
const existing = tabs.value.find((tab) => tab.mode === "plugin-filesystem" && tab.pluginFilesystem?.pluginId === pluginId && tab.pluginFilesystem?.providerId === providerId && tab.connectionId === (options.connectionId || ""));
if (existing) {
if (options.currentUri) existing.pluginFilesystem = { ...existing.pluginFilesystem!, currentUri: options.currentUri };
switchTab(existing.id);
return existing.id;
}
}
const id = uuid();
const tab: QueryTab = {
id,
title: options.title || providerId,
connectionId: options.connectionId || "",
database: "",
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "plugin-filesystem",
pluginFilesystem: {
pluginId,
providerId,
rootUri: options.rootUri,
currentUri: options.currentUri,
},
};
return registerOpenTab(tab);
}
async function openPluginConnection(connectionId: string) {
const connectionStore = useConnectionStore();
const connection = connectionStore.getConfig(connectionId);
if (!connection || connection.db_type !== "plugin") throw new Error("Plugin connection config not found");
const pluginId = connection.plugin_id;
const providerId = connection.plugin_connection_provider;
if (!pluginId || !providerId) throw new Error("Plugin connection binding is incomplete");
const registry = createFrontendPluginRegistry(await api.listPlugins(), currentLocale());
const provider = registry.listConnectionProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === providerId);
if (!provider) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' is unavailable`);
const workbench = provider.contribution.workbench ? registry.findWorkbench(pluginId, provider.contribution.workbench) : undefined;
await connectionStore.ensureConnected(connectionId);
if (workbench) {
return openPluginWorkbench(pluginId, workbench.contribution.id, {
title: `${connection.name} · ${workbench.contribution.label}`,
connectionId,
context: {
connectionId,
providerId,
connectionType: connection.plugin_connection_type,
},
});
}
const filesystemProviderId = provider.contribution.filesystem_provider;
const filesystem = filesystemProviderId ? registry.listFilesystemProviders().find((entry) => entry.plugin.manifest.id === pluginId && entry.contribution.id === filesystemProviderId) : undefined;
if (!filesystem) throw new Error(`Plugin connection provider '${pluginId}/${providerId}' does not declare a workbench or filesystem provider`);
return openPluginFilesystem(pluginId, filesystem.contribution.id, {
title: `${connection.name} · ${filesystem.contribution.label}`,
connectionId,
rootUri: filesystem.contribution.root_uri,
});
}
function applyTableStructureInitialTab(tab: QueryTab, initialTab?: TableInfoTab, initialTarget?: TableStructureEditorTarget) {
if (!initialTab && !initialTarget?.name) return;
if (initialTab) tab.structureInitialTab = initialTab;
@@ -8088,6 +8184,9 @@ export const useQueryStore = defineStore("query", () => {
openMqttAdmin,
openNacosAdmin,
clearNacosNavigationTarget,
openPluginWorkbench,
openPluginFilesystem,
openPluginConnection,
openTableStructure,
linkSavedSql,
linkExternalSqlPath,
+3
View File
@@ -900,6 +900,7 @@ export interface EditorSettings {
export interface ToolbarItems {
dataTransfer: boolean;
driverManager: boolean;
pluginCenter: boolean;
sqlFile: boolean;
schemaDiff: boolean;
dataCompare: boolean;
@@ -916,6 +917,7 @@ export interface ToolbarItems {
export const DEFAULT_TOOLBAR_ITEMS: ToolbarItems = {
dataTransfer: true,
driverManager: true,
pluginCenter: true,
sqlFile: true,
schemaDiff: true,
dataCompare: true,
@@ -1369,6 +1371,7 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
return {
dataTransfer: items.dataTransfer ?? defaults.dataTransfer,
driverManager: items.driverManager ?? defaults.driverManager,
pluginCenter: items.pluginCenter ?? defaults.pluginCenter,
sqlFile: items.sqlFile ?? defaults.sqlFile,
schemaDiff: items.schemaDiff ?? defaults.schemaDiff,
dataCompare: items.dataCompare ?? defaults.dataCompare,
+328 -2
View File
@@ -131,6 +131,10 @@ export interface ConnectionConfig {
gbase_server?: string;
informix_server?: string;
external_config?: unknown;
plugin_id?: string;
plugin_connection_provider?: string;
plugin_connection_type?: string;
connection_secrets?: Record<string, string>;
one_time?: boolean;
/**
* Whether the database password may be persisted locally. When false, the
@@ -260,19 +264,328 @@ export interface PluginDriverManifest {
database_type?: string;
}
export type PluginFormFieldType = "text" | "password" | "number" | "boolean" | "select" | "radio" | "textarea";
export type PluginFormFieldBinding = "config" | "secret" | "name" | "host" | "port" | "username" | "password" | "database";
export type PluginFormFieldValue = string | number | boolean | undefined;
export interface PluginFormFieldOption {
label: string;
value: string;
}
export interface PluginFormField {
key: string;
label: string;
type: PluginFormFieldType;
description?: string;
placeholder?: string;
required?: boolean;
default?: PluginFormFieldValue;
options?: PluginFormFieldOption[];
binding?: PluginFormFieldBinding;
}
export type PluginConnectionCapability = "test" | "connect" | "disconnect";
export type PluginConnectionActionKind = "test" | "save" | "save-and-connect" | "custom";
export type PluginConnectionActionVariant = "default" | "outline" | "secondary" | "destructive" | "ghost";
export type PluginConnectionActionWhen = "always" | "create" | "edit";
export interface PluginConnectionActionContribution {
id: string;
label: string;
description?: string;
variant?: PluginConnectionActionVariant;
when?: PluginConnectionActionWhen;
close_on_success?: boolean;
requires_valid_form?: boolean;
timeout_ms?: number;
}
export interface PluginConnectionAction {
id: string;
kind: PluginConnectionActionKind;
label?: string;
description?: string;
variant?: PluginConnectionActionVariant;
when?: PluginConnectionActionWhen;
close_on_success?: boolean;
requires_valid_form?: boolean;
timeout_ms?: number;
}
export interface PluginConnectionProviderContribution {
type: "connection-provider";
id: string;
label: string;
icon?: string;
database_type: string;
description?: string;
fields: PluginFormField[];
workbench?: string;
filesystem_provider?: string;
capabilities?: PluginConnectionCapability[];
actions?: PluginConnectionActionContribution[];
}
export interface PluginWorkbenchContribution {
type: "workbench";
id: string;
label: string;
description?: string;
icon?: string;
}
export interface PluginFilesystemProviderContribution {
type: "filesystem-provider";
id: string;
label: string;
schemes: string[];
description?: string;
root_uri?: string;
capabilities?: Array<"read" | "write" | "delete" | "rename" | "mkdir">;
}
export type PluginFilesystemEntryKind = "file" | "directory" | "symlink" | "other";
export interface PluginFilesystemEntry {
name: string;
uri: string;
kind: PluginFilesystemEntryKind;
size?: number;
modifiedAt?: string;
contentType?: string;
}
export interface PluginFilesystemListResult {
entries: PluginFilesystemEntry[];
nextCursor?: string;
}
export interface PluginFilesystemReadResult {
dataBase64: string;
contentType?: string;
truncated: boolean;
etag?: string;
}
export interface PluginFilesystemMutationResult {
success: boolean;
message?: string;
entry?: PluginFilesystemEntry;
}
export interface PluginContextMenuContribution {
type: "context-menu";
id: string;
label: string;
description?: string;
icon?: string;
menu: string;
}
export interface PluginResultViewContribution {
type: "result-view";
id: string;
label: string;
description?: string;
icon?: string;
}
export type PluginContribution = PluginConnectionProviderContribution | PluginWorkbenchContribution | PluginFilesystemProviderContribution | PluginContextMenuContribution | PluginResultViewContribution;
export interface PluginEngines {
dbx: string;
host_api: string;
}
export interface PluginBackendEntrypoint {
protocol_versions?: number[];
transport?: "stdio-jsonl" | "stdio-framed";
executable: string;
}
export interface PluginUiEntrypoint {
root?: string;
entry: string;
}
export interface PluginEntrypoints {
backend?: PluginBackendEntrypoint;
ui?: PluginUiEntrypoint;
}
export interface PluginFormFieldLocalization {
label?: string;
description?: string;
placeholder?: string;
options?: Record<string, string>;
}
export interface PluginContributionLocalization {
label?: string;
description?: string;
fields?: Record<string, PluginFormFieldLocalization>;
actions?: Record<string, { label?: string; description?: string }>;
}
export interface PluginManifestLocalization {
name?: string;
description?: string;
contributions?: Record<string, PluginContributionLocalization>;
}
export interface PluginCompatibility {
compatible: boolean;
errors?: string[];
warnings?: string[];
target?: string;
}
export interface PluginManifest {
manifest_version?: number;
id: string;
name: string;
icon?: string;
version?: string;
publisher?: string;
engines?: PluginEngines;
permissions?: string[];
entrypoints?: PluginEntrypoints;
protocol_version?: number;
description?: string;
source?: string;
homepage?: string;
executable?: string;
drivers: PluginDriverManifest[];
contributions?: PluginContribution[];
localizations?: Record<string, PluginManifestLocalization>;
}
export interface InstalledPlugin {
manifest: PluginManifest;
path: string;
compatibility: PluginCompatibility;
path?: string;
}
export interface PluginTrustedKey {
keyId: string;
publicKey: string;
}
export type PluginRepositoryKind = "official" | "custom" | "enterprise";
export interface PluginRepository {
id: string;
name: string;
kind: PluginRepositoryKind;
catalogUrl?: string;
enabled: boolean;
managed: boolean;
}
export interface PluginMarketplaceRepositoryMetadata {
id: string;
name: string;
homepage?: string;
}
export interface PluginMarketplaceLocalization {
name?: string;
description?: string;
}
export interface PluginMarketplaceArtifact {
target: string;
url: string;
sha256: string;
signingKeyId: string;
size?: number;
}
export interface PluginMarketplaceVersion {
version: string;
releasedAt?: string;
releaseNotes?: string;
artifacts: PluginMarketplaceArtifact[];
}
export interface PluginMarketplacePlugin {
id: string;
name: string;
description: string;
publisher: string;
verified: boolean;
icon?: string;
tags: string[];
permissions: string[];
source?: string;
homepage?: string;
license?: string;
latestVersion: string;
versions: PluginMarketplaceVersion[];
localizations?: Record<string, PluginMarketplaceLocalization>;
}
export interface PluginMarketplaceCatalog {
catalogVersion: number;
repository: PluginMarketplaceRepositoryMetadata;
generatedAt?: string;
plugins: PluginMarketplacePlugin[];
}
export interface PluginRepositoryCatalogResult {
repository: PluginRepository;
target: string;
catalog?: PluginMarketplaceCatalog;
error?: string;
}
export interface PluginMarketplaceInstallRequest {
repositoryId: string;
pluginId: string;
version?: string;
}
export interface ActivePluginSession {
pluginId: string;
processId?: number;
state: "starting" | "running" | "stopping" | "stopped" | "exited";
}
export interface PluginUiAssetPayload {
contentType: string;
dataBase64: string;
etag: string;
}
export interface PluginConnectionActionResult {
message?: string;
fieldValues?: Record<string, PluginFormFieldValue | null>;
}
export interface PluginInstallResult {
plugin: InstalledPlugin;
previousVersion?: string;
packageSha256: string;
signature: { status: "trusted"; key_id: string } | { status: "unsigned" };
}
export interface PluginRollbackResult {
plugin: InstalledPlugin;
previousVersion: string;
}
export interface PluginEvent {
pluginId: string;
method: string;
params: unknown;
}
export interface PluginBinaryEvent {
pluginId: string;
channel: string;
dataBase64: string;
}
export interface JdbcDriverInfo {
@@ -1296,7 +1609,20 @@ export interface QueryTab {
| "mysql-dashboard"
| "postgres-dashboard"
| "xugu-dashboard"
| "dolt-version-control";
| "dolt-version-control"
| "plugin-workbench"
| "plugin-filesystem";
pluginWorkbench?: {
pluginId: string;
contributionId: string;
context?: Record<string, unknown>;
};
pluginFilesystem?: {
pluginId: string;
providerId: string;
rootUri?: string;
currentUri?: string;
};
/** Ephemeral navigation intent; it is consumed by HBaseBrowser and is not persisted. */
hbaseCreateTableOnOpen?: boolean;
mqTenant?: string;
@@ -80,6 +80,7 @@ export const DATABASE_TYPES = [
"victoriametrics",
"jdbc",
"spark",
"plugin",
] as const;
export type DatabaseType = (typeof DATABASE_TYPES)[number];
+3
View File
@@ -20,6 +20,9 @@ system-fonts = ["font-kit"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] }
semver = "1"
ed25519-dalek = "2"
fs2 = "0.4"
utoipa = { version = "5.5", optional = true }
unicode-width = "0.2"
unicode-ident = "1"
@@ -2850,6 +2850,35 @@
"userAdmin": false,
"driverManagement": true
}
},
{
"dbType": "plugin",
"label": "Plugin",
"runtimeMode": "external",
"mcpMode": "unsupported",
"singleConnectionPool": false,
"metadataConnectionScoped": false,
"skipTcpProbe": true,
"supportLevel": "connect",
"specializedSurface": true,
"capabilities": {
"queryExecution": false,
"metadataBrowse": false,
"objectBrowser": false,
"objectSource": false,
"schemaSearch": false,
"diagram": false,
"tableDataEdit": false,
"tableStructureEdit": false,
"tableImport": false,
"dataTransfer": false,
"sqlFileExecution": false,
"databaseCreate": false,
"fieldLineage": false,
"sqlExplain": false,
"userAdmin": false,
"driverManagement": false
}
}
]
}
@@ -0,0 +1,56 @@
use dbx_core::plugins::{
PluginMarketplace, PluginMarketplaceInstallRequest, PluginSignatureStatus, OFFICIAL_PLUGIN_REPOSITORY_ID,
};
const APP_VERSION: &str = "0.5.68";
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("plugin marketplace smoke failed: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let store = tempfile::tempdir().map_err(|error| error.to_string())?;
let marketplace = PluginMarketplace::new(store.path().to_path_buf(), APP_VERSION)?;
let results = marketplace.fetch_catalogs().await;
let official = results
.iter()
.find(|result| result.repository.id == OFFICIAL_PLUGIN_REPOSITORY_ID)
.ok_or("Official DBX Marketplace result is missing")?;
if let Some(error) = &official.error {
return Err(error.clone());
}
let catalog = official.catalog.as_ref().ok_or("Official DBX Marketplace catalog is missing")?;
let mut arguments = std::env::args().skip(1);
let Some(plugin_id) = arguments.next() else {
println!("plugin marketplace smoke passed: fetched {} official catalog entries", catalog.plugins.len());
return Ok(());
};
let version = arguments.next();
if arguments.next().is_some() {
return Err("Usage: plugin_marketplace_smoke [plugin-id] [version]".to_string());
}
if !catalog.plugins.iter().any(|plugin| plugin.id == plugin_id) {
return Err(format!("Official DBX Marketplace does not list '{plugin_id}'"));
}
let installed = marketplace
.install(PluginMarketplaceInstallRequest {
repository_id: OFFICIAL_PLUGIN_REPOSITORY_ID.to_string(),
plugin_id: plugin_id.clone(),
version,
})
.await?;
if installed.plugin.manifest.id != plugin_id {
return Err(format!("Expected plugin '{plugin_id}', got '{}'", installed.plugin.manifest.id));
}
match installed.signature {
PluginSignatureStatus::Trusted { key_id } => {
println!("plugin marketplace smoke passed: catalog -> release download -> sha256 -> signature {key_id} -> install");
}
signature => return Err(format!("Unexpected marketplace signature status: {signature:?}")),
}
Ok(())
}
@@ -0,0 +1,160 @@
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::plugins::{PluginHost, PluginInstallPolicy, PluginPackageInstaller, PluginRegistry, PluginTrustStore};
use serde_json::{json, Value};
const PLUGIN_ID: &str = "dbx.example.hello";
const APP_VERSION: &str = "0.5.68";
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("plugin package smoke failed: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let package = std::env::args().nth(1).map(PathBuf::from).ok_or("Usage: plugin_package_smoke <package.dbxp>")?;
let store = tempfile::tempdir().map_err(|error| error.to_string())?;
let trusted_keys = std::env::var("DBX_PLUGIN_SMOKE_TRUSTED_KEYS_JSON")
.ok()
.map(|raw| serde_json::from_str::<BTreeMap<String, String>>(&raw).map_err(|error| error.to_string()))
.transpose()?;
let (installer, policy) = match trusted_keys {
Some(keys) => (
PluginPackageInstaller::with_trust_store(
store.path().to_path_buf(),
APP_VERSION.to_string(),
PluginTrustStore::from_base64_keys(keys)?,
),
PluginInstallPolicy::LocalSigned,
),
None => (
PluginPackageInstaller::new(store.path().to_path_buf(), APP_VERSION.to_string())?,
PluginInstallPolicy::LocalDevelopment,
),
};
let installed = installer.install_file(&package, policy)?;
if installed.plugin.manifest.id != PLUGIN_ID {
return Err(format!("Expected plugin '{PLUGIN_ID}', got '{}'", installed.plugin.manifest.id));
}
let registry = PluginRegistry::new_with_app_version(store.path().to_path_buf(), APP_VERSION);
let plugin_icon = installed.plugin.manifest.icon.as_deref().ok_or("Example plugin does not declare an icon")?;
let provider_icon = installed
.plugin
.manifest
.connection_provider("dbx.example.hello.connection")?
.and_then(|provider| provider.icon)
.ok_or("Example connection provider does not declare an icon")?;
for icon in [plugin_icon, provider_icon.as_str()] {
let asset = registry.read_asset(PLUGIN_ID, icon)?;
if !asset.content_type.starts_with("image/") || asset.bytes.is_empty() {
return Err(format!("Invalid packaged plugin icon '{icon}'"));
}
}
let host = PluginHost::new(registry);
let mut events = host.subscribe_events();
let config: ConnectionConfig = serde_json::from_value(json!({
"id": "hello-smoke-connection",
"name": "Hello smoke connection",
"db_type": "plugin",
"driver_profile": "plugin",
"host": "localhost",
"port": 22,
"username": "",
"password": "",
"database": null,
"external_config": { "greeting": "Hello" },
"plugin_id": PLUGIN_ID,
"plugin_connection_provider": "dbx.example.hello.connection",
"plugin_connection_type": "hello",
"connection_secrets": { "api_token": "smoke-secret" }
}))
.map_err(|error| error.to_string())?;
let test = host.test_connection(&config, "localhost", 22).await?;
if !test.message.contains("localhost:22") {
return Err(format!("Unexpected connection-test result: {}", test.message));
}
let action = host.invoke_connection_action(&config, "suggest-greeting", "localhost", 22).await?;
if action.message.as_deref() != Some("Greeting updated by the plugin action.")
|| action.field_values.get("greeting").and_then(Value::as_str) != Some("Hello from plugin action")
{
return Err(format!("Unexpected connection-action result: {action:?}"));
}
let handle = host.connect_connection(&config, "localhost", 22).await?;
if !handle.is_running() {
return Err("Plugin connection handle is not running".to_string());
}
let greeting: Value = host
.invoke(
PLUGIN_ID,
"hello/greet",
json!({ "connectionId": config.id, "name": "Smoke" }),
None,
Some(Duration::from_secs(5)),
)
.await?;
if greeting.get("message").and_then(Value::as_str) != Some("Hello, Smoke, from Hello smoke connection!") {
return Err(format!("Unexpected greeting: {greeting}"));
}
let files = host
.list_filesystem_entries(
PLUGIN_ID,
"dbx.example.hello.files",
Some(&config.id),
Some("hello:/"),
None,
Some(20),
)
.await?;
if !files.entries.iter().any(|entry| entry.name == "README.txt") {
return Err(format!("Unexpected filesystem listing: {files:?}"));
}
let preview = host
.read_filesystem_file(PLUGIN_ID, "dbx.example.hello.files", Some(&config.id), "hello:/README.txt", Some(1024))
.await?;
if preview.truncated || preview.data_base64.is_empty() {
return Err(format!("Unexpected filesystem preview: {preview:?}"));
}
let mut saw_connected = false;
let mut saw_finished = false;
for _ in 0..4 {
let event = tokio::time::timeout(Duration::from_secs(2), events.recv())
.await
.map_err(|_| "Timed out waiting for plugin events".to_string())?
.map_err(|error| error.to_string())?;
if event.method == "hello/connectionChanged"
&& event.params.get("state").and_then(Value::as_str) == Some("connected")
{
saw_connected = true;
}
if event.method == "hello/progress" && event.params.get("stage").and_then(Value::as_str) == Some("finished") {
saw_finished = true;
}
if saw_connected && saw_finished {
break;
}
}
if !saw_connected || !saw_finished {
return Err(format!("Missing expected events: connected={saw_connected}, finished={saw_finished}"));
}
handle.disconnect().await?;
host.stop_all().await;
installer.uninstall(PLUGIN_ID)?;
if !PluginRegistry::new_with_app_version(store.path().to_path_buf(), APP_VERSION).list_installed()?.is_empty() {
return Err("Plugin store is not empty after uninstall".to_string());
}
println!("plugin package smoke passed: install -> assets -> action -> test -> connect -> invoke -> filesystem -> events -> disconnect -> uninstall");
Ok(())
}
@@ -204,6 +204,10 @@ fn connection_config(id: &str, database: BenchDatabase) -> Result<ConnectionConf
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+4
View File
@@ -763,6 +763,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+6 -5
View File
@@ -190,13 +190,12 @@ mod tests {
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
let plugin = InstalledPlugin {
manifest: PluginManifest {
let plugin = InstalledPlugin::new(
PluginManifest {
id: "jdbc".to_string(),
name: "JDBC".to_string(),
version: "test".to_string(),
protocol_version: 1,
description: String::new(),
executable: Some("plugin.sh".to_string()),
drivers: vec![PluginDriverManifest {
id: "jdbc".to_string(),
@@ -204,9 +203,11 @@ mod tests {
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..PluginManifest::default()
},
path: dir.clone(),
};
dir.clone(),
env!("CARGO_PKG_VERSION"),
);
let session = Arc::new(
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default()).await.unwrap(),
);
+4
View File
@@ -1490,6 +1490,10 @@ for line in sys.stdin:
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+47 -2
View File
@@ -15,7 +15,7 @@ use crate::ai::AiConfigItem;
use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TRUSTSTORE_PASSWORD_KEY, MQ_AUTH_API_KEY_VALUE_KEY,
MQ_AUTH_CLIENT_SECRET_KEY, MQ_AUTH_PASSWORD_KEY, MQ_AUTH_TOKEN_KEY, MQ_TOKEN_SIGNING_KEY, NACOS_AUTH_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::models::connection::{ConnectionConfig, DatabaseType, TransportLayerConfig};
use crate::saved_sql::SavedSqlLibrary;
@@ -973,6 +973,7 @@ fn scrub_connection_secrets(config: &mut ConnectionConfig) {
scrub_mq_external_config_secrets(config);
scrub_nacos_auth_secrets(config);
scrub_cassandra_tls_secrets(config);
config.connection_secrets.clear();
}
fn scrub_mqtt_auth_secrets(config: &mut ConnectionConfig) {
@@ -1008,6 +1009,14 @@ async fn build_sensitive_payload(
if config.save_password {
push_secret(&mut connection_secrets, &config.id, "password", &config.password);
}
for (key, secret) in &config.connection_secrets {
push_secret(
&mut connection_secrets,
&config.id,
&format!("{PLUGIN_CONNECTION_SECRET_PREFIX}{key}"),
secret,
);
}
push_secret(&mut connection_secrets, &config.id, "init_script", config.init_script.as_deref().unwrap_or(""));
for (index, layer) in config.transport_layers.iter().enumerate() {
match layer {
@@ -1225,6 +1234,7 @@ async fn apply_sensitive_payload(
if !SECRET_KEYS.contains(&secret.key.as_str())
&& !secret.key.starts_with(SSH_TUNNEL_SECRET_PREFIX)
&& !secret.key.starts_with(TRANSPORT_LAYER_SECRET_PREFIX)
&& !secret.key.starts_with(PLUGIN_CONNECTION_SECRET_PREFIX)
{
continue;
}
@@ -1264,6 +1274,7 @@ async fn clear_connection_secrets(storage: &Storage, connections: &[ConnectionCo
for key in SECRET_KEYS {
storage.delete_secret(&config.id, key).await?;
}
storage.delete_secret_prefix(&config.id, PLUGIN_CONNECTION_SECRET_PREFIX).await?;
for (index, layer) in config.transport_layers.iter().enumerate() {
match layer {
TransportLayerConfig::Ssh(_) => {
@@ -1654,7 +1665,7 @@ mod tests {
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiConfigItem};
use crate::connection_secrets::{
CASSANDRA_KEYSTORE_PASSWORD_KEY, CASSANDRA_TRUSTSTORE_PASSWORD_KEY, NACOS_AUTH_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY,
NACOS_RNACOS_CONSOLE_PASSWORD_KEY, PLUGIN_CONNECTION_SECRET_PREFIX,
};
use crate::models::connection::{
default_redis_key_separator, ConnectionConfig, DatabaseType, SshTunnelConfig, TransportLayerConfig,
@@ -1847,6 +1858,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -1934,6 +1949,10 @@ mod tests {
"password": password
}
})),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -2187,6 +2206,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -2649,6 +2672,28 @@ mod tests {
}));
}
#[tokio::test]
async fn plugin_connection_secrets_are_scrubbed_from_public_sync_metadata() {
let storage = Storage::open(&temp_db_path("plugin-public-sync")).await.unwrap();
let mut config = postgres_connection("plugin", "");
config.db_type = DatabaseType::Plugin;
config.plugin_id = Some("example.plugin".to_string());
config.plugin_connection_provider = Some("example.connection".to_string());
config.connection_secrets.insert("api_token".to_string(), "plugin-secret".to_string());
storage.save_connections(std::slice::from_ref(&config)).await.unwrap();
let snapshot = build_sync_snapshot(&storage, "test-version", None, Some("sync-pass")).await.unwrap();
let public_json = serde_json::to_string(&snapshot.connections).unwrap();
assert!(!public_json.contains("plugin-secret"));
let encrypted = snapshot.encrypted_secrets.as_ref().expect("encrypted secrets");
let decrypted = decrypt_sensitive_payload(encrypted, "sync-pass").unwrap();
assert!(decrypted.connection_secrets.iter().any(|secret| {
secret.connection_id == "plugin"
&& secret.key == format!("{PLUGIN_CONNECTION_SECRET_PREFIX}api_token")
&& secret.secret == "plugin-secret"
}));
}
#[tokio::test]
async fn sync_restore_does_not_revive_password_when_connection_disables_saving() {
let source = Storage::open(&temp_db_path("sync-no-save-password-source")).await.unwrap();
+93 -3
View File
@@ -29,7 +29,7 @@ use crate::models::connection::{
use crate::mongo_oidc::MongoOidcBrowserOpener;
use crate::nacos::config::{NACOS_CONSOLE_SESSION_PASSWORD, NACOS_PRIMARY_SESSION_PASSWORD};
use crate::path_utils::expand_tilde;
use crate::plugins::{PluginDriverSession, PluginRegistry, PluginRuntimeEnv};
use crate::plugins::{PluginConnectionHandle, PluginDriverSession, PluginHost, PluginRegistry, PluginRuntimeEnv};
use crate::query_cancel::RunningQueries;
use crate::session_credentials::SessionCredentialStore;
use crate::storage::{normalize_duckdb_worker_max_processes, Storage, DUCKDB_WORKER_MAX_PROCESSES_DEFAULT};
@@ -110,6 +110,7 @@ pub enum PoolKind {
config: Arc<ConnectionConfig>,
session: Arc<PluginDriverSession>,
},
PluginConnection(PluginConnectionHandle),
/// Message queue admin connection (not a data query pool; serves as a
/// marker that this connection_id is a valid MQ admin connection).
MessageQueue,
@@ -331,6 +332,7 @@ pub struct AppState {
pub http_tunnels: HttpTunnelManager,
pub storage: Storage,
pub plugins: PluginRegistry,
pub plugin_host: PluginHost,
pub agent_manager: crate::agent_manager::AgentManager,
pub nacos_registry: crate::nacos::NacosAdminRegistry,
duckdb_worker_process_isolation: AtomicBool,
@@ -1345,6 +1347,7 @@ impl AppState {
app_version: impl Into<String>,
) -> Self {
let data_dir = storage.data_dir().to_path_buf();
let app_version = app_version.into();
Self {
connections: Arc::new(RwLock::new(ConnectionPoolRegistry::new())),
task_supervisor: TaskSupervisor::new(),
@@ -1357,7 +1360,8 @@ impl AppState {
proxy_tunnels: ProxyTunnelManager::new(),
http_tunnels: HttpTunnelManager::new(),
storage,
plugins: PluginRegistry::new(plugin_dir),
plugins: PluginRegistry::new_with_app_version(plugin_dir.clone(), app_version.clone()),
plugin_host: PluginHost::new(PluginRegistry::new_with_app_version(plugin_dir, app_version.clone())),
agent_manager: crate::agent_manager::AgentManager::new_with_base_dir_and_app_version(
agent_dir,
app_version,
@@ -2777,6 +2781,7 @@ impl AppState {
}
self.external_driver_pool("jdbc", &jdbc_config).await?
}
DatabaseType::Plugin => return Err("Plugin-owned connections use the plugin host".to_string()),
#[cfg(feature = "mq-admin")]
DatabaseType::MessageQueue => {
// MQ admin connections don't hold a data query pool. We just test
@@ -3833,6 +3838,7 @@ impl AppState {
| PoolKind::MessageQueue
| PoolKind::Nacos
| PoolKind::Consul(_) => false,
PoolKind::PluginConnection(handle) => !handle.is_running(),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => false,
}
@@ -4867,6 +4873,7 @@ impl AppState {
| PoolKind::MessageQueue
| PoolKind::Nacos
| PoolKind::Consul(_) => true,
PoolKind::PluginConnection(handle) => handle.is_running(),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => true,
PoolKind::Redis(redis) => match db::redis_driver::test_connection(redis).await {
@@ -4971,6 +4978,20 @@ impl AppState {
self.pool_routing_control().close_removed(removed).await;
}
pub async fn remove_plugin_connection_pools(&self, plugin_id: &str) {
let removed = self.drain_plugin_connection_pools(plugin_id).await;
self.pool_routing_control().close_removed(removed).await;
}
pub async fn invoke_plugin_connection_action(
&self,
config: ConnectionConfig,
action_id: &str,
) -> Result<crate::plugins::PluginConnectionActionResult, String> {
let (host, port) = self.connection_host_port(&config.id, &config).await?;
self.plugin_host.invoke_connection_action(&config, action_id, &host, port).await
}
async fn drain_connection_pools(&self, connection_id: &str) -> Vec<(String, PoolKind)> {
let pool_prefix = format!("{connection_id}:");
let keys_to_remove: Vec<String> = self
@@ -5063,6 +5084,34 @@ impl AppState {
removed
}
async fn drain_plugin_connection_pools(&self, plugin_id: &str) -> Vec<(String, PoolKind)> {
let keys_to_remove: Vec<String> = self
.connections
.read()
.await
.iter()
.filter_map(|(key, pool)| match pool {
PoolKind::PluginConnection(handle) if handle.plugin_id == plugin_id => Some(key.clone()),
_ => None,
})
.collect();
self.stop_keepalive_tasks(&keys_to_remove).await;
{
let mut activity = self.pool_activity.write().await;
for key in &keys_to_remove {
activity.remove(key);
}
}
let mut conns = self.connections.write().await;
let mut removed = Vec::with_capacity(keys_to_remove.len());
for key in keys_to_remove {
if let Some(pool) = conns.remove(&key) {
removed.push((key, pool));
}
}
removed
}
async fn uses_forwarded_transport(&self, connection_id: &str) -> bool {
let configs = self.configs.read().await;
configs.get(connection_id).is_some_and(|config| config.has_effective_transport_layers())
@@ -5601,7 +5650,41 @@ fn pool_key_for_session_role(
#[cfg(test)]
fn clone_pool_kind(pool: &PoolKind) -> PoolKind {
pool.clone()
match pool {
PoolKind::Mysql(p, mode) => PoolKind::Mysql(p.clone(), *mode),
PoolKind::Postgres(p) => PoolKind::Postgres(p.clone()),
PoolKind::Sqlite(p) => PoolKind::Sqlite(p.clone()),
PoolKind::Rqlite(client) => PoolKind::Rqlite(client.clone()),
PoolKind::Turso(client) => PoolKind::Turso(client.clone()),
PoolKind::CloudflareD1(client) => PoolKind::CloudflareD1(client.clone()),
#[cfg(feature = "duckdb-sidecar")]
PoolKind::DuckDbWorker(client) => PoolKind::DuckDbWorker(client.clone()),
#[cfg(not(feature = "duckdb-sidecar"))]
PoolKind::DuckDbWorker(_) => PoolKind::DuckDbWorker(()),
PoolKind::MongoDb(client) => PoolKind::MongoDb(client.clone()),
PoolKind::DynamoDb(client) => PoolKind::DynamoDb(client.clone()),
PoolKind::ClickHouse(client) => PoolKind::ClickHouse(client.clone()),
PoolKind::SqlServer(client) => PoolKind::SqlServer(client.clone()),
PoolKind::Elasticsearch(client) => PoolKind::Elasticsearch(client.clone()),
PoolKind::Easysearch(client) => PoolKind::Easysearch(client.clone()),
PoolKind::Meilisearch(client) => PoolKind::Meilisearch(client.clone()),
PoolKind::HBase(client) => PoolKind::HBase(client.clone()),
PoolKind::VectorDb(client) => PoolKind::VectorDb(client.clone()),
PoolKind::InfluxDb(client) => PoolKind::InfluxDb(client.clone()),
PoolKind::InfluxDb3(client) => PoolKind::InfluxDb3(client.clone()),
PoolKind::VictoriaMetrics(client) => PoolKind::VictoriaMetrics(client.clone()),
PoolKind::Agent(client) => PoolKind::Agent(client.clone()),
PoolKind::ExternalDriver { driver_id, config, session } => {
PoolKind::ExternalDriver { driver_id: driver_id.clone(), config: config.clone(), session: session.clone() }
}
PoolKind::PluginConnection(handle) => PoolKind::PluginConnection(handle.clone()),
PoolKind::MessageQueue => PoolKind::MessageQueue,
PoolKind::Nacos => PoolKind::Nacos,
PoolKind::Consul(client) => PoolKind::Consul(client.clone()),
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(client) => PoolKind::Mqtt(Arc::clone(client)),
PoolKind::Redis(_) => panic!("clone_pool_kind not supported for Redis — handled separately"),
}
}
async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
@@ -5668,6 +5751,9 @@ async fn close_pool_kind(pool: PoolKind) -> Result<(), String> {
PoolKind::ExternalDriver { session, .. } => {
session.shutdown().await;
}
PoolKind::PluginConnection(handle) => {
handle.disconnect().await?;
}
PoolKind::MessageQueue => {}
PoolKind::Nacos => {}
PoolKind::Consul(_) => {}
@@ -6078,6 +6164,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -27,6 +27,7 @@ pub const MQTT_AUTH_PASSWORD_KEY: &str = "mqtt.auth.password";
pub const CASSANDRA_TLS_SECRET_PREFIX: &str = "cassandra.tls.";
pub const CASSANDRA_TRUSTSTORE_PASSWORD_KEY: &str = "cassandra.tls.truststore_password";
pub const CASSANDRA_KEYSTORE_PASSWORD_KEY: &str = "cassandra.tls.keystore_password";
pub const PLUGIN_CONNECTION_SECRET_PREFIX: &str = "plugin_connection.";
pub trait ConnectionSecretStore {
fn set_secret(&self, connection_id: &str, key: &str, secret: &str) -> Result<(), String>;
@@ -947,6 +948,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+4
View File
@@ -7334,6 +7334,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: HashMap::new(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+39
View File
@@ -196,6 +196,19 @@ pub struct ConnectionConfig {
/// Typed configuration for external tabular sources.
#[serde(default)]
pub external_config: Option<serde_json::Value>,
/// Owning plugin for a first-class plugin connection.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_id: Option<String>,
/// `connection-provider` contribution id inside `plugin_id`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_connection_provider: Option<String>,
/// Provider-defined connection kind, such as `ssh` or `s3`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plugin_connection_type: Option<String>,
/// Provider-defined sensitive values. Persistence moves these into the
/// connection secret store rather than keeping them in `config_json`.
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub connection_secrets: HashMap<String, String>,
#[serde(default)]
pub jdbc_driver_class: Option<String>,
#[serde(default)]
@@ -632,6 +645,14 @@ struct ConnectionConfigData {
#[serde(default)]
pub external_config: Option<serde_json::Value>,
#[serde(default)]
pub plugin_id: Option<String>,
#[serde(default)]
pub plugin_connection_provider: Option<String>,
#[serde(default)]
pub plugin_connection_type: Option<String>,
#[serde(default)]
pub connection_secrets: HashMap<String, String>,
#[serde(default)]
pub jdbc_driver_class: Option<String>,
#[serde(default)]
pub jdbc_driver_paths: Vec<String>,
@@ -702,6 +723,10 @@ impl From<ConnectionConfigData> for ConnectionConfig {
gbase_server: data.gbase_server,
informix_server: data.informix_server,
external_config: data.external_config,
plugin_id: data.plugin_id,
plugin_connection_provider: data.plugin_connection_provider,
plugin_connection_type: data.plugin_connection_type,
connection_secrets: data.connection_secrets,
jdbc_driver_class: data.jdbc_driver_class,
jdbc_driver_paths: data.jdbc_driver_paths,
one_time: data.one_time,
@@ -1173,6 +1198,11 @@ impl ConnectionConfig {
format!("{scheme}://{host}:{port}")
}
DatabaseType::Jdbc => "jdbc:<redacted>".to_string(),
DatabaseType::Plugin => format!(
"plugin://{}/{}",
self.plugin_id.as_deref().unwrap_or("unknown"),
self.plugin_connection_type.as_deref().unwrap_or("unknown")
),
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Mqtt => self.mqtt_broker_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
@@ -1451,6 +1481,11 @@ impl ConnectionConfig {
DatabaseType::Jdbc => {
self.connection_string.as_deref().filter(|value| !value.is_empty()).unwrap_or("jdbc:").to_string()
}
DatabaseType::Plugin => format!(
"plugin://{}/{}",
self.plugin_id.as_deref().unwrap_or("unknown"),
self.plugin_connection_type.as_deref().unwrap_or("unknown")
),
DatabaseType::MessageQueue => self.message_queue_admin_url(),
DatabaseType::Mqtt => self.mqtt_broker_url(),
DatabaseType::Nacos => self.nacos_admin_url(),
@@ -2753,6 +2788,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+4
View File
@@ -228,6 +228,10 @@ mod tests {
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
external_config: Some(value),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
+4
View File
@@ -948,6 +948,10 @@ mod tests {
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
external_config: Some(serde_json::json!({
"systemKind": "pulsar",
"adminUrl": "http://127.0.0.1:8080",
+4
View File
@@ -416,6 +416,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: Some(value),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+8
View File
@@ -692,6 +692,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: Some(serde_json::json!({ "serverAddr": "http://127.0.0.1:9" })),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: std::collections::HashMap::new(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -770,6 +774,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: Some(serde_json::json!({ "serverAddr": "http://127.0.0.1:9" })),
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: std::collections::HashMap::new(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+319 -390
View File
@@ -1,52 +1,98 @@
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
use tokio::time::timeout;
use serde::Serialize;
use tokio::process::Command;
const PLUGIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1;
mod assets;
mod filesystem;
mod host;
mod installer;
mod manifest;
mod marketplace;
mod runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginManifest {
pub id: String,
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default = "default_plugin_protocol_version")]
pub protocol_version: u32,
#[serde(default)]
pub description: String,
#[serde(default)]
pub executable: Option<String>,
#[serde(default)]
pub drivers: Vec<PluginDriverManifest>,
}
pub use assets::PluginUiAsset;
pub use filesystem::{
PluginFilesystemEntry, PluginFilesystemEntryKind, PluginFilesystemListResult, PluginFilesystemMutationResult,
PluginFilesystemReadResult, DEFAULT_PLUGIN_FILESYSTEM_PAGE_SIZE, DEFAULT_PLUGIN_FILESYSTEM_PREVIEW_BYTES,
MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES, MAX_PLUGIN_FILESYSTEM_PAGE_SIZE, MAX_PLUGIN_FILESYSTEM_PREVIEW_BYTES,
PLUGIN_FILESYSTEM_CREATE_DIRECTORY_METHOD, PLUGIN_FILESYSTEM_DELETE_METHOD, PLUGIN_FILESYSTEM_LIST_METHOD,
PLUGIN_FILESYSTEM_READ_METHOD, PLUGIN_FILESYSTEM_RENAME_METHOD, PLUGIN_FILESYSTEM_WRITE_METHOD,
};
pub use host::{ActivePluginSession, PluginConnectionActionResult, PluginConnectionHandle, PluginHost};
pub use installer::{
PluginInstallPolicy, PluginInstallResponse, PluginInstallResult, PluginPackageInstaller, PluginRollbackResponse,
PluginRollbackResult, PluginSignatureStatus, PluginTrustStore, PluginTrustedKey, DBXP_EXTENSION,
MAX_PLUGIN_PACKAGE_BYTES, PLUGIN_CHECKSUMS_FILE, PLUGIN_SIGNATURE_FILE,
};
pub use marketplace::{
PluginMarketplace, PluginMarketplaceArtifact, PluginMarketplaceCatalog, PluginMarketplaceInstallRequest,
PluginMarketplaceLocalization, PluginMarketplacePlugin, PluginMarketplaceRepositoryMetadata,
PluginMarketplaceVersion, PluginRepository, PluginRepositoryCatalogResult, PluginRepositoryKind,
PluginRepositoryStore, MAX_PLUGIN_CATALOG_BYTES, OFFICIAL_PLUGIN_REPOSITORY_ID, SUPPORTED_PLUGIN_CATALOG_VERSION,
};
fn default_plugin_protocol_version() -> u32 {
1
}
pub use manifest::{
current_plugin_target, resolve_safe_plugin_path, PluginBackendEntrypoint, PluginBackendTransport,
PluginCompatibility, PluginConnectionActionContribution, PluginConnectionActionVariant, PluginConnectionActionWhen,
PluginConnectionCapability, PluginConnectionProviderContribution, PluginContribution, PluginDriverManifest,
PluginEngines, PluginEntrypoints, PluginFilesystemCapability, PluginFilesystemProviderContribution,
PluginFormFieldBinding, PluginFormFieldDefinition, PluginFormFieldOption, PluginFormFieldType, PluginManifest,
PluginUiEntrypoint, PluginWorkbenchContribution, PLUGIN_CONNECTION_ACTION_METHOD, PLUGIN_CONNECTION_CONNECT_METHOD,
PLUGIN_CONNECTION_DISCONNECT_METHOD, PLUGIN_CONNECTION_TEST_METHOD, SUPPORTED_PLUGIN_HOST_API_VERSION,
SUPPORTED_PLUGIN_MANIFEST_VERSION, SUPPORTED_PLUGIN_PERMISSIONS, SUPPORTED_PLUGIN_PROTOCOL_VERSION,
};
pub use runtime::{
PluginBinaryMessage, PluginEvent, PluginHandshake, PluginHandshakeIdentity, PluginSessionState,
PluginSessionStatus, PluginSidecarSession, PLUGIN_REQUEST_TIMEOUT,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginDriverManifest {
pub id: String,
pub label: String,
pub kind: String,
#[serde(default)]
pub database_type: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone)]
pub struct InstalledPlugin {
pub manifest: PluginManifest,
pub path: PathBuf,
pub compatibility: PluginCompatibility,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledPluginInfo {
pub manifest: PluginManifest,
pub compatibility: PluginCompatibilityInfo,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginCompatibilityInfo {
pub compatible: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl InstalledPlugin {
pub fn new(manifest: PluginManifest, path: PathBuf, app_version: &str) -> Self {
let compatibility = manifest.compatibility(&path, app_version);
Self { manifest, path, compatibility }
}
pub fn info(&self) -> InstalledPluginInfo {
InstalledPluginInfo {
manifest: self.manifest.clone(),
compatibility: PluginCompatibilityInfo {
compatible: self.compatibility.compatible,
errors: self.compatibility.errors.clone(),
warnings: self.compatibility.warnings.clone(),
target: self.compatibility.target.clone(),
},
}
}
}
#[derive(Debug, Clone, Default)]
@@ -74,17 +120,26 @@ impl PluginRuntimeEnv {
#[derive(Debug, Clone)]
pub struct PluginRegistry {
root_dir: PathBuf,
app_version: String,
}
impl PluginRegistry {
pub fn new(root_dir: PathBuf) -> Self {
Self { root_dir }
Self::new_with_app_version(root_dir, env!("CARGO_PKG_VERSION"))
}
pub fn new_with_app_version(root_dir: PathBuf, app_version: impl Into<String>) -> Self {
Self { root_dir, app_version: app_version.into() }
}
pub fn root_dir(&self) -> &Path {
&self.root_dir
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub fn list_installed(&self) -> Result<Vec<InstalledPlugin>, String> {
let entries = match std::fs::read_dir(&self.root_dir) {
Ok(entries) => entries,
@@ -95,18 +150,43 @@ impl PluginRegistry {
let mut plugins = Vec::new();
for entry in entries {
let entry = entry.map_err(|err| err.to_string())?;
let path = entry.path();
if !path.is_dir() {
let container_path = entry.path();
if !container_path.is_dir() {
continue;
}
let path = match installer::resolve_active_plugin_dir(&container_path) {
Ok(Some(path)) => path,
Ok(None) => continue,
Err(error) => {
plugins.push(unavailable_plugin(container_path, error));
continue;
}
};
let manifest_path = path.join("manifest.json");
if !manifest_path.exists() {
continue;
}
let raw = std::fs::read_to_string(&manifest_path).map_err(|err| err.to_string())?;
let manifest: PluginManifest = serde_json::from_str(&raw)
.map_err(|err| format!("Failed to parse plugin manifest {}: {err}", manifest_path.display()))?;
plugins.push(InstalledPlugin { manifest, path });
let raw = match std::fs::read_to_string(&manifest_path) {
Ok(raw) => raw,
Err(error) => {
plugins.push(unavailable_plugin(
container_path,
format!("Failed to read plugin manifest {}: {error}", manifest_path.display()),
));
continue;
}
};
let manifest: PluginManifest = match serde_json::from_str(&raw) {
Ok(manifest) => manifest,
Err(error) => {
plugins.push(unavailable_plugin(
container_path,
format!("Failed to parse plugin manifest {}: {error}", manifest_path.display()),
));
continue;
}
};
plugins.push(InstalledPlugin::new(manifest, path, &self.app_version));
}
plugins.sort_by(|a, b| a.manifest.id.cmp(&b.manifest.id));
Ok(plugins)
@@ -114,14 +194,19 @@ impl PluginRegistry {
pub fn find_driver(&self, driver_id: &str) -> Result<Option<InstalledPlugin>, String> {
Ok(self.list_installed()?.into_iter().find(|plugin| {
plugin
.manifest
.drivers
.iter()
.any(|driver| driver.id == driver_id || driver.database_type.as_deref() == Some(driver_id))
plugin.compatibility.compatible
&& plugin
.manifest
.drivers
.iter()
.any(|driver| driver.id == driver_id || driver.database_type.as_deref() == Some(driver_id))
}))
}
pub fn find_plugin(&self, plugin_id: &str) -> Result<Option<InstalledPlugin>, String> {
Ok(self.list_installed()?.into_iter().find(|plugin| plugin.manifest.id == plugin_id))
}
pub async fn invoke_driver<T>(&self, driver_id: &str, method: &str, params: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
@@ -155,14 +240,11 @@ impl PluginRegistry {
{
let plugin =
self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?;
ensure_plugin_protocol_compatible(&plugin.manifest)?;
let invoke = invoke_plugin(&plugin, driver_id, method, params, &env);
match timeout_duration {
Some(duration) => timeout(duration, invoke).await.map_err(|_| {
format!("Plugin '{}' timed out after {} seconds", plugin.manifest.id, duration.as_secs())
})?,
None => invoke.await,
}
ensure_plugin_compatible(&plugin)?;
let session = PluginSidecarSession::start(plugin, self.app_version.clone(), env).await?;
let result = session.invoke_with_timeout(method, params, Some(driver_id), timeout_duration).await;
session.shutdown().await;
result
}
pub async fn start_driver_session(&self, driver_id: &str) -> Result<Arc<PluginDriverSession>, String> {
@@ -176,85 +258,57 @@ impl PluginRegistry {
) -> Result<Arc<PluginDriverSession>, String> {
let plugin =
self.find_driver(driver_id)?.ok_or_else(|| format!("Plugin driver '{driver_id}' is not installed"))?;
ensure_plugin_protocol_compatible(&plugin.manifest)?;
PluginDriverSession::start(plugin, driver_id.to_string(), env).await.map(Arc::new)
ensure_plugin_compatible(&plugin)?;
PluginDriverSession::start(plugin, driver_id.to_string(), self.app_version.clone(), env).await.map(Arc::new)
}
}
fn ensure_plugin_protocol_compatible(manifest: &PluginManifest) -> Result<(), String> {
if manifest.protocol_version == SUPPORTED_PLUGIN_PROTOCOL_VERSION {
fn unavailable_plugin(path: PathBuf, error: String) -> InstalledPlugin {
let id = path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.unwrap_or("invalid-plugin")
.to_string();
InstalledPlugin {
manifest: PluginManifest {
id: id.clone(),
name: id,
version: "0.0.0-invalid".to_string(),
description: error.clone(),
..PluginManifest::default()
},
path,
compatibility: PluginCompatibility {
compatible: false,
errors: vec![error],
target: Some(current_plugin_target()),
..PluginCompatibility::default()
},
}
}
fn ensure_plugin_compatible(plugin: &InstalledPlugin) -> Result<(), String> {
if plugin.compatibility.compatible {
return Ok(());
}
Err(format!(
"Plugin '{}' uses protocol version {}, but this DBX build supports protocol version {}",
manifest.id, manifest.protocol_version, SUPPORTED_PLUGIN_PROTOCOL_VERSION
))
}
#[derive(Debug, Serialize)]
struct PluginRequest {
jsonrpc: &'static str,
id: u64,
driver: String,
method: String,
params: serde_json::Value,
}
#[derive(Debug, Deserialize)]
struct PluginResponse {
id: u64,
#[serde(default)]
result: Option<serde_json::Value>,
#[serde(default)]
error: Option<PluginError>,
}
#[derive(Debug, Deserialize)]
struct PluginError {
message: String,
Err(format!("Plugin '{}' is incompatible: {}", plugin.manifest.id, plugin.compatibility.errors.join("; ")))
}
pub struct PluginDriverSession {
plugin: InstalledPlugin,
sidecar: Arc<PluginSidecarSession>,
driver_id: String,
process: Mutex<PluginProcess>,
next_request_id: AtomicU64,
}
struct PluginProcess {
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
}
impl PluginDriverSession {
async fn start(plugin: InstalledPlugin, driver_id: String, env: PluginRuntimeEnv) -> Result<Self, String> {
let mut child = spawn_plugin_child(&plugin, &env)?;
let stdin = child.stdin.take().ok_or("Plugin stdin unavailable")?;
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
if let Some(stderr) = child.stderr.take() {
let plugin_id = plugin.manifest.id.clone();
tokio::spawn(async move {
let mut stderr = BufReader::new(stderr);
loop {
match read_plugin_line(&mut stderr, "stderr").await {
Ok(line) => log::warn!("[plugin:{plugin_id}] {}", line.trim_end()),
Err(err) if err.contains("end of stream") => break,
Err(err) => {
log::warn!("[plugin:{plugin_id}] failed to read stderr: {err}");
break;
}
}
}
});
}
Ok(Self {
plugin,
driver_id,
process: Mutex::new(PluginProcess { child, stdin, stdout: BufReader::new(stdout) }),
next_request_id: AtomicU64::new(1),
})
async fn start(
plugin: InstalledPlugin,
driver_id: String,
app_version: String,
env: PluginRuntimeEnv,
) -> Result<Self, String> {
let sidecar = PluginSidecarSession::start(plugin, app_version, env).await?;
Ok(Self { sidecar, driver_id })
}
pub async fn invoke<T>(&self, method: &str, params: serde_json::Value) -> Result<T, String>
@@ -273,68 +327,27 @@ impl PluginDriverSession {
where
T: DeserializeOwned,
{
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let invoke = async {
let mut process = self.process.lock().await;
self.invoke_locked(&mut process, request_id, method, params).await
};
match timeout_duration {
Some(duration) => match timeout(duration, invoke).await {
Ok(result) => result,
Err(_) => {
self.kill().await;
Err(format!("Plugin '{}' timed out after {} seconds", self.plugin.manifest.id, duration.as_secs()))
}
},
None => invoke.await,
}
}
async fn invoke_locked<T>(
&self,
process: &mut PluginProcess,
request_id: u64,
method: &str,
params: serde_json::Value,
) -> Result<T, String>
where
T: DeserializeOwned,
{
let request = PluginRequest {
jsonrpc: "2.0",
id: request_id,
driver: self.driver_id.clone(),
method: method.to_string(),
params,
};
let line = encode_plugin_request_line(&request)?;
process.stdin.write_all(&line).await.map_err(|err| err.to_string())?;
process.stdin.flush().await.map_err(|err| err.to_string())?;
let stdout = &mut process.stdout;
let child = &mut process.child;
read_decoded_plugin_response(&self.plugin, request_id, stdout, || {
let status = child.try_wait().map_err(|err| err.to_string())?;
Ok(match status {
Some(status) => format!("Plugin '{}' exited with status {}", self.plugin.manifest.id, status),
None => format!("Plugin '{}' closed stdout without a response", self.plugin.manifest.id),
})
})
.await
}
async fn kill(&self) {
let mut process = self.process.lock().await;
let _ = process.child.kill().await;
self.sidecar.invoke_with_timeout(method, params, Some(&self.driver_id), timeout_duration).await
}
pub async fn shutdown(&self) {
self.kill().await;
self.sidecar.shutdown().await;
}
pub async fn pid(&self) -> Option<u32> {
let process = self.process.lock().await;
process.child.id()
self.sidecar.pid().await
}
pub fn subscribe_events(&self) -> tokio::sync::broadcast::Receiver<PluginEvent> {
self.sidecar.subscribe_events()
}
pub fn subscribe_binary(&self) -> tokio::sync::broadcast::Receiver<PluginBinaryMessage> {
self.sidecar.subscribe_binary()
}
pub async fn send_binary(&self, channel: &str, data: &[u8]) -> Result<(), String> {
self.sidecar.send_binary(channel, data).await
}
#[cfg(all(test, unix))]
@@ -343,216 +356,15 @@ impl PluginDriverSession {
driver_id: String,
env: PluginRuntimeEnv,
) -> Result<Self, String> {
Self::start(plugin, driver_id, env).await
Self::start(plugin, driver_id, env!("CARGO_PKG_VERSION").to_string(), env).await
}
}
async fn invoke_plugin<T>(
plugin: &InstalledPlugin,
driver_id: &str,
method: &str,
params: serde_json::Value,
env: &PluginRuntimeEnv,
) -> Result<T, String>
where
T: DeserializeOwned,
{
let mut child = spawn_plugin_child(plugin, env)?;
let request =
PluginRequest { jsonrpc: "2.0", id: 1, driver: driver_id.to_string(), method: method.to_string(), params };
let line = encode_plugin_request_line(&request)?;
let mut stdin = child.stdin.take().ok_or("Plugin stdin unavailable")?;
stdin.write_all(&line).await.map_err(|err| err.to_string())?;
drop(stdin);
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
let mut stderr = child.stderr.take().ok_or("Plugin stderr unavailable")?;
let mut reader = BufReader::new(stdout);
let response_result = read_decoded_plugin_response(plugin, request.id, &mut reader, || {
Ok(format!("Plugin '{}' exited without a response", plugin.manifest.id))
})
.await;
let mut stderr_bytes = Vec::new();
stderr.read_to_end(&mut stderr_bytes).await.map_err(|err| err.to_string())?;
let stderr_text = String::from_utf8_lossy(&stderr_bytes).into_owned();
let status = child.wait().await.map_err(|err| err.to_string())?;
let response = match response_result {
Ok(response) => response,
Err(err) if err.contains("end of stream") => {
let stderr = stderr_text.trim().to_string();
return Err(if stderr.is_empty() {
format!("Plugin '{}' exited without a response", plugin.manifest.id)
} else {
format!("Plugin '{}' exited without a response: {stderr}", plugin.manifest.id)
});
}
Err(err) => return Err(err),
};
if !status.success() {
let stderr = stderr_text.trim().to_string();
return Err(if stderr.is_empty() {
format!("Plugin '{}' exited with status {}", plugin.manifest.id, status)
} else {
format!("Plugin '{}' failed: {stderr}", plugin.manifest.id)
});
}
Ok(response)
}
fn encode_plugin_request_line(request: &PluginRequest) -> Result<Vec<u8>, String> {
let mut bytes = Vec::new();
serde_json::to_writer(&mut bytes, request).map_err(|err| err.to_string())?;
bytes.push(b'\n');
Ok(bytes)
}
fn spawn_plugin_child(plugin: &InstalledPlugin, env: &PluginRuntimeEnv) -> Result<Child, String> {
let executable = plugin
.manifest
.executable
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| format!("Plugin '{}' does not declare an executable", plugin.manifest.id))?;
let executable_path = resolve_plugin_executable(&plugin.path, executable);
let mut command = crate::process::new_tokio_command(&executable_path);
command
.current_dir(&plugin.path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
env.apply_to(&mut command);
command.spawn().map_err(|err| format!("Failed to start plugin '{}': {err}", plugin.manifest.id))
}
async fn read_plugin_line<R>(reader: &mut R, context: &str) -> Result<String, String>
where
R: tokio::io::AsyncBufRead + Unpin,
{
let mut bytes = Vec::new();
reader.read_until(b'\n', &mut bytes).await.map_err(|err| format!("Failed to read plugin {context}: {err}"))?;
if bytes.is_empty() {
return Err(format!("Failed to read plugin {context}: end of stream"));
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
async fn read_decoded_plugin_response<T, R, F>(
plugin: &InstalledPlugin,
request_id: u64,
reader: &mut R,
end_of_stream_message: F,
) -> Result<T, String>
where
T: DeserializeOwned,
R: tokio::io::AsyncBufRead + Unpin,
F: FnMut() -> Result<String, String>,
{
let mut end_of_stream_message = end_of_stream_message;
const MAX_NOISY_LINES: usize = 20;
for _ in 0..MAX_NOISY_LINES {
let line = match read_plugin_line(reader, "response").await {
Ok(line) => line,
Err(err) if err.contains("end of stream") => return Err(end_of_stream_message()?),
Err(err) => return Err(err),
};
match decode_plugin_response(plugin, request_id, &line) {
Ok(response) => return Ok(response),
Err(err) if err.starts_with(&format!("Failed to parse plugin '{}' response:", plugin.manifest.id)) => {
log::warn!("[plugin:{}] ignored non-protocol stdout: {}", plugin.manifest.id, line.trim_end());
}
Err(err) => return Err(err),
}
}
match read_plugin_line(reader, "response").await {
Ok(_) => {
Err(format!("Plugin '{}' wrote too many non-protocol stdout lines before its response", plugin.manifest.id))
}
Err(err) if err.contains("end of stream") => Err(end_of_stream_message()?),
Err(err) => Err(err),
}
}
fn decode_plugin_response<T>(plugin: &InstalledPlugin, request_id: u64, response_line: &str) -> Result<T, String>
where
T: DeserializeOwned,
{
let response: PluginResponse = serde_json::from_str(response_line)
.map_err(|err| format!("Failed to parse plugin '{}' response: {err}", plugin.manifest.id))?;
if response.id != request_id {
return Err(format!("Plugin '{}' returned mismatched response id", plugin.manifest.id));
}
if let Some(error) = response.error {
return Err(error.message);
}
let result = response.result.unwrap_or(serde_json::Value::Null);
serde_json::from_value(result)
.map_err(|err| format!("Failed to decode plugin '{}' result: {err}", plugin.manifest.id))
}
fn resolve_plugin_executable(plugin_dir: &Path, executable: &str) -> PathBuf {
let path = PathBuf::from(executable);
let resolved = if path.is_absolute() { path } else { plugin_dir.join(path) };
#[cfg(windows)]
{
let bat = resolved.with_extension("bat");
if bat.exists() {
return bat;
}
}
resolved
}
#[cfg(test)]
mod tests {
use super::{read_decoded_plugin_response, read_plugin_line, InstalledPlugin, PluginManifest};
use super::{InstalledPlugin, PluginContribution, PluginManifest, PluginRegistry};
#[cfg(unix)]
use super::{PluginDriverManifest, PluginDriverSession, PluginRuntimeEnv};
use std::path::PathBuf;
use tokio::io::BufReader;
#[tokio::test]
async fn reads_non_utf8_plugin_lines_lossily() {
let bytes = vec![b'{', b'"', b'e', b'r', b'r', b'o', b'r', b'"', b':', 0xB2, 0xE2, b'}', b'\n'];
let mut reader = BufReader::new(std::io::Cursor::new(bytes));
let line = read_plugin_line(&mut reader, "response").await.expect("line should be readable");
assert_eq!(line, format!("{{\"error\":{}}}\n", "\u{fffd}\u{fffd}"));
}
#[tokio::test]
async fn skips_noisy_plugin_stdout_before_json_response() {
let plugin = InstalledPlugin {
manifest: PluginManifest {
id: "jdbc".to_string(),
name: "JDBC".to_string(),
version: "test".to_string(),
protocol_version: 1,
description: String::new(),
executable: None,
drivers: Vec::new(),
},
path: PathBuf::new(),
};
let bytes = b"driver banner\n{\"id\":1,\"result\":{\"ok\":true}}\n";
let mut reader = BufReader::new(std::io::Cursor::new(bytes));
let result: serde_json::Value =
read_decoded_plugin_response(&plugin, 1, &mut reader, || Ok("closed".to_string()))
.await
.expect("response should decode after noisy line");
assert_eq!(result["ok"], true);
}
#[cfg(unix)]
#[tokio::test]
@@ -568,8 +380,8 @@ mod tests {
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
}
let plugin = InstalledPlugin {
manifest: PluginManifest {
let plugin = InstalledPlugin::new(
PluginManifest {
id: "jdbc".to_string(),
name: "JDBC".to_string(),
version: "test".to_string(),
@@ -582,13 +394,21 @@ mod tests {
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
contributions: Vec::new(),
..PluginManifest::default()
},
path: dir.clone(),
};
dir.clone(),
env!("CARGO_PKG_VERSION"),
);
let session = PluginDriverSession::start(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
.expect("session should start");
let session = PluginDriverSession::start(
plugin,
"jdbc".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
PluginRuntimeEnv::default(),
)
.await
.expect("session should start");
let pid = session.pid().await.expect("child should have a pid");
session.shutdown().await;
@@ -608,4 +428,113 @@ mod tests {
.map(|status| status.success())
.unwrap_or(false)
}
#[test]
fn preserves_typed_contributions_in_manifest_round_trip() {
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "example",
"engines": { "host_api": "1" },
"contributions": [{
"type": "connection-provider",
"id": "sample.connection",
"database_type": "sample",
"fields": []
}]
}))
.expect("manifest should parse");
assert!(matches!(
&manifest.contributions[0],
PluginContribution::ConnectionProvider(provider) if provider.id == "sample.connection"
));
let serialized = serde_json::to_value(manifest).expect("manifest should serialize");
assert_eq!(serialized["contributions"][0]["database_type"], "sample");
}
#[test]
fn registry_lists_frontend_contributions_from_disk() {
let root = std::env::temp_dir().join(format!("dbx-plugin-registry-test-{}", uuid::Uuid::new_v4()));
let plugin_dir = root.join("sample");
std::fs::create_dir_all(&plugin_dir).expect("plugin directory should be created");
std::fs::write(
plugin_dir.join("manifest.json"),
serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "example",
"engines": { "host_api": "1" },
"contributions": [{
"type": "connection-provider",
"id": "sample.connection",
"database_type": "sample",
"fields": []
}]
})
.to_string(),
)
.expect("manifest should be written");
let plugins = PluginRegistry::new(root.clone()).list_installed().expect("plugins should be listed");
assert_eq!(plugins.len(), 1);
assert!(matches!(
&plugins[0].manifest.contributions[0],
PluginContribution::ConnectionProvider(provider) if provider.id == "sample.connection"
));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn registry_keeps_other_plugins_visible_when_one_manifest_is_corrupt() {
let root = std::env::temp_dir().join(format!("dbx-plugin-corrupt-registry-test-{}", uuid::Uuid::new_v4()));
let valid = root.join("valid");
let corrupt = root.join("corrupt");
std::fs::create_dir_all(&valid).unwrap();
std::fs::create_dir_all(&corrupt).unwrap();
std::fs::write(
valid.join("manifest.json"),
serde_json::json!({ "id": "valid", "name": "Valid", "drivers": [] }).to_string(),
)
.unwrap();
std::fs::write(corrupt.join("manifest.json"), "{").unwrap();
let plugins = PluginRegistry::new(root.clone()).list_installed().unwrap();
assert_eq!(plugins.len(), 2);
assert!(plugins.iter().any(|plugin| plugin.manifest.id == "valid" && plugin.compatibility.compatible));
assert!(plugins.iter().any(|plugin| {
plugin.manifest.id == "corrupt"
&& !plugin.compatibility.compatible
&& plugin.compatibility.errors.iter().any(|error| error.contains("Failed to parse"))
}));
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn installed_plugin_info_hides_runtime_paths() {
let plugin = InstalledPlugin::new(
PluginManifest {
manifest_version: 1,
id: "sample".to_string(),
name: "Sample".to_string(),
version: "1.0.0".to_string(),
publisher: "example".to_string(),
..PluginManifest::default()
},
std::path::PathBuf::from("/private/plugin/install"),
env!("CARGO_PKG_VERSION"),
);
let serialized = serde_json::to_value(plugin.info()).unwrap();
assert!(serialized.get("path").is_none());
assert!(serialized["compatibility"].get("backend_executable").is_none());
assert!(serialized["compatibility"].get("ui_entry").is_none());
assert!(serialized["compatibility"].get("ui_root").is_none());
}
}
+184
View File
@@ -0,0 +1,184 @@
use std::path::{Component, Path, PathBuf};
use sha2::{Digest, Sha256};
use super::{InstalledPlugin, PluginRegistry};
const MAX_PLUGIN_UI_ASSET_BYTES: u64 = 32 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct PluginUiAsset {
pub content_type: String,
pub bytes: Vec<u8>,
pub etag: String,
}
impl PluginRegistry {
pub fn read_asset(&self, plugin_id: &str, relative_path: &str) -> Result<PluginUiAsset, String> {
let plugin = self.find_plugin(plugin_id)?.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
let relative = validate_asset_path(relative_path)?;
let path = plugin.path.join(relative);
read_asset_file(plugin_id, &plugin.path, &path)
}
pub fn read_ui_entry(&self, plugin_id: &str) -> Result<PluginUiAsset, String> {
let plugin = self.compatible_ui_plugin(plugin_id)?;
let entry = plugin
.compatibility
.ui_entry
.as_ref()
.ok_or_else(|| format!("Plugin '{plugin_id}' does not provide a UI entrypoint"))?;
let root = plugin
.compatibility
.ui_root
.as_ref()
.ok_or_else(|| format!("Plugin '{plugin_id}' does not provide a UI root"))?;
read_asset_file(plugin_id, root, entry)
}
pub fn read_ui_asset(&self, plugin_id: &str, relative_path: &str) -> Result<PluginUiAsset, String> {
let plugin = self.compatible_ui_plugin(plugin_id)?;
let root = plugin
.compatibility
.ui_root
.as_ref()
.ok_or_else(|| format!("Plugin '{plugin_id}' does not provide a UI root"))?;
let relative = validate_asset_path(relative_path)?;
let path = root.join(relative);
read_asset_file(plugin_id, root, &path)
}
fn compatible_ui_plugin(&self, plugin_id: &str) -> Result<InstalledPlugin, String> {
let plugin = self.find_plugin(plugin_id)?.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
if !plugin.compatibility.compatible {
return Err(format!("Plugin '{plugin_id}' is incompatible: {}", plugin.compatibility.errors.join("; ")));
}
if plugin.manifest.entrypoints.ui.is_none() {
return Err(format!("Plugin '{plugin_id}' does not provide a UI entrypoint"));
}
Ok(plugin)
}
}
fn read_asset_file(plugin_id: &str, root: &Path, path: &Path) -> Result<PluginUiAsset, String> {
let canonical_root = std::fs::canonicalize(root)
.map_err(|error| format!("Failed to resolve plugin asset root {}: {error}", root.display()))?;
let canonical_path = std::fs::canonicalize(path)
.map_err(|error| format!("Plugin UI asset does not exist {}: {error}", path.display()))?;
if !canonical_path.starts_with(&canonical_root) {
return Err(format!("Plugin asset escapes plugin '{plugin_id}': {}", path.display()));
}
let metadata = std::fs::metadata(&canonical_path).map_err(|error| error.to_string())?;
if !metadata.is_file() {
return Err(format!("Plugin UI asset is not a file: {}", canonical_path.display()));
}
if metadata.len() > MAX_PLUGIN_UI_ASSET_BYTES {
return Err(format!("Plugin UI asset exceeds {MAX_PLUGIN_UI_ASSET_BYTES} bytes"));
}
let bytes = std::fs::read(&canonical_path).map_err(|error| error.to_string())?;
let etag = format!("\"{}\"", hex_digest(Sha256::digest(&bytes)));
Ok(PluginUiAsset { content_type: content_type(&canonical_path).to_string(), bytes, etag })
}
fn validate_asset_path(relative_path: &str) -> Result<PathBuf, String> {
let path = Path::new(relative_path);
if path.as_os_str().is_empty() || path.is_absolute() {
return Err("Plugin UI asset path must be a non-empty relative path".to_string());
}
if path.components().any(|component| !matches!(component, Component::Normal(_))) {
return Err(format!("Plugin UI asset path is unsafe: {relative_path}"));
}
Ok(path.to_path_buf())
}
fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(|extension| extension.to_str()).map(str::to_ascii_lowercase).as_deref() {
Some("html" | "htm") => "text/html; charset=utf-8",
Some("js" | "mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json" | "map") => "application/json; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("wasm") => "application/wasm",
Some("txt") => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn hex_digest(bytes: impl AsRef<[u8]>) -> String {
use std::fmt::Write as _;
bytes.as_ref().iter().fold(String::with_capacity(64), |mut output, byte| {
let _ = write!(output, "{byte:02x}");
output
})
}
#[cfg(test)]
mod tests {
use super::PluginRegistry;
#[test]
fn serves_assets_inside_declared_ui_root() {
let root = tempfile::tempdir().unwrap();
let plugin_dir = root.path().join("sample");
std::fs::create_dir_all(plugin_dir.join("ui")).unwrap();
std::fs::write(plugin_dir.join("ui/index.html"), "<h1>Hello</h1>").unwrap();
std::fs::write(plugin_dir.join("ui/app.js"), "console.log('hello')").unwrap();
std::fs::write(
plugin_dir.join("manifest.json"),
serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "dbx",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } }
})
.to_string(),
)
.unwrap();
let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67");
let entry = registry.read_ui_entry("sample").unwrap();
assert_eq!(entry.content_type, "text/html; charset=utf-8");
assert_eq!(entry.bytes, b"<h1>Hello</h1>");
assert!(registry.read_ui_asset("sample", "app.js").is_ok());
assert!(registry.read_ui_asset("sample", "../manifest.json").is_err());
}
#[test]
fn serves_package_assets_without_a_ui_entrypoint() {
let root = tempfile::tempdir().unwrap();
let plugin_dir = root.path().join("sample");
std::fs::create_dir_all(plugin_dir.join("assets")).unwrap();
std::fs::write(plugin_dir.join("assets/icon.svg"), "<svg/>").unwrap();
std::fs::write(
plugin_dir.join("manifest.json"),
serde_json::json!({
"manifest_version": 1,
"id": "sample",
"name": "Sample",
"version": "1.0.0",
"publisher": "dbx",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"icon": "assets/icon.svg"
})
.to_string(),
)
.unwrap();
let registry = PluginRegistry::new_with_app_version(root.path().to_path_buf(), "0.5.67");
let icon = registry.read_asset("sample", "assets/icon.svg").unwrap();
assert_eq!(icon.content_type, "image/svg+xml");
assert_eq!(icon.bytes, b"<svg/>");
assert!(registry.read_asset("sample", "../outside.svg").is_err());
}
}
+536
View File
@@ -0,0 +1,536 @@
use std::time::Duration;
use base64::Engine;
use serde::{Deserialize, Serialize};
use super::{PluginFilesystemCapability, PluginFilesystemProviderContribution, PluginHost, PLUGIN_REQUEST_TIMEOUT};
pub const PLUGIN_FILESYSTEM_LIST_METHOD: &str = "filesystem/list";
pub const PLUGIN_FILESYSTEM_READ_METHOD: &str = "filesystem/read";
pub const PLUGIN_FILESYSTEM_WRITE_METHOD: &str = "filesystem/write";
pub const PLUGIN_FILESYSTEM_CREATE_DIRECTORY_METHOD: &str = "filesystem/createDirectory";
pub const PLUGIN_FILESYSTEM_DELETE_METHOD: &str = "filesystem/delete";
pub const PLUGIN_FILESYSTEM_RENAME_METHOD: &str = "filesystem/rename";
pub const DEFAULT_PLUGIN_FILESYSTEM_PAGE_SIZE: u32 = 200;
pub const MAX_PLUGIN_FILESYSTEM_PAGE_SIZE: u32 = 1_000;
pub const DEFAULT_PLUGIN_FILESYSTEM_PREVIEW_BYTES: u64 = 256 * 1024;
pub const MAX_PLUGIN_FILESYSTEM_PREVIEW_BYTES: u64 = 4 * 1024 * 1024;
pub const MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES: u64 = 4 * 1024 * 1024;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemListRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
uri: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
cursor: Option<&'a str>,
limit: u32,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemReadRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
uri: &'a str,
max_bytes: u64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemWriteRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
uri: &'a str,
data_base64: &'a str,
create: bool,
overwrite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
etag: Option<&'a str>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemCreateDirectoryRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
uri: &'a str,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemDeleteRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
uri: &'a str,
recursive: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginFilesystemRenameRequest<'a> {
provider_id: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
connection_id: Option<&'a str>,
source_uri: &'a str,
target_uri: &'a str,
overwrite: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum PluginFilesystemEntryKind {
File,
Directory,
Symlink,
Other,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginFilesystemEntry {
pub name: String,
pub uri: String,
pub kind: PluginFilesystemEntryKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modified_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginFilesystemListResult {
#[serde(default)]
pub entries: Vec<PluginFilesystemEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginFilesystemReadResult {
pub data_base64: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_type: Option<String>,
#[serde(default)]
pub truncated: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginFilesystemMutationResult {
#[serde(default = "default_true")]
pub success: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry: Option<PluginFilesystemEntry>,
}
fn default_true() -> bool {
true
}
impl PluginHost {
pub async fn list_filesystem_entries(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
uri: Option<&str>,
cursor: Option<&str>,
limit: Option<u32>,
) -> Result<PluginFilesystemListResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
let uri = resolve_filesystem_uri(&provider, uri)?;
let limit = limit.unwrap_or(DEFAULT_PLUGIN_FILESYSTEM_PAGE_SIZE).clamp(1, MAX_PLUGIN_FILESYSTEM_PAGE_SIZE);
let request = PluginFilesystemListRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
uri: &uri,
cursor: normalized_optional_value(cursor),
limit,
};
let result: PluginFilesystemListResult = self
.invoke(
plugin_id,
PLUGIN_FILESYSTEM_LIST_METHOD,
serde_json::to_value(request).map_err(|error| error.to_string())?,
None,
Some(PLUGIN_REQUEST_TIMEOUT),
)
.await?;
validate_list_result(&provider, &result, limit)?;
Ok(result)
}
pub async fn read_filesystem_file(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
uri: &str,
max_bytes: Option<u64>,
) -> Result<PluginFilesystemReadResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
ensure_provider_capability(plugin_id, &provider, PluginFilesystemCapability::Read)?;
let uri = resolve_filesystem_uri(&provider, Some(uri))?;
let max_bytes =
max_bytes.unwrap_or(DEFAULT_PLUGIN_FILESYSTEM_PREVIEW_BYTES).clamp(1, MAX_PLUGIN_FILESYSTEM_PREVIEW_BYTES);
let request = PluginFilesystemReadRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
uri: &uri,
max_bytes,
};
let result: PluginFilesystemReadResult = self
.invoke(
plugin_id,
PLUGIN_FILESYSTEM_READ_METHOD,
serde_json::to_value(request).map_err(|error| error.to_string())?,
None,
Some(Duration::from_secs(30)),
)
.await?;
validate_read_result(&result, max_bytes)?;
Ok(result)
}
pub async fn write_filesystem_file(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
uri: &str,
data_base64: &str,
create: bool,
overwrite: bool,
etag: Option<&str>,
) -> Result<PluginFilesystemMutationResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
ensure_provider_capability(plugin_id, &provider, PluginFilesystemCapability::Write)?;
let uri = resolve_filesystem_uri(&provider, Some(uri))?;
validate_inline_write_data(data_base64)?;
let request = PluginFilesystemWriteRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
uri: &uri,
data_base64,
create,
overwrite,
etag: normalized_optional_value(etag),
};
let result =
self.invoke_filesystem_mutation(plugin_id, &provider, PLUGIN_FILESYSTEM_WRITE_METHOD, request).await?;
Ok(result)
}
pub async fn create_filesystem_directory(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
uri: &str,
) -> Result<PluginFilesystemMutationResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
ensure_provider_capability(plugin_id, &provider, PluginFilesystemCapability::Mkdir)?;
let uri = resolve_filesystem_uri(&provider, Some(uri))?;
let request = PluginFilesystemCreateDirectoryRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
uri: &uri,
};
self.invoke_filesystem_mutation(plugin_id, &provider, PLUGIN_FILESYSTEM_CREATE_DIRECTORY_METHOD, request).await
}
pub async fn delete_filesystem_entry(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
uri: &str,
recursive: bool,
) -> Result<PluginFilesystemMutationResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
ensure_provider_capability(plugin_id, &provider, PluginFilesystemCapability::Delete)?;
let uri = resolve_filesystem_uri(&provider, Some(uri))?;
let request = PluginFilesystemDeleteRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
uri: &uri,
recursive,
};
self.invoke_filesystem_mutation(plugin_id, &provider, PLUGIN_FILESYSTEM_DELETE_METHOD, request).await
}
pub async fn rename_filesystem_entry(
&self,
plugin_id: &str,
provider_id: &str,
connection_id: Option<&str>,
source_uri: &str,
target_uri: &str,
overwrite: bool,
) -> Result<PluginFilesystemMutationResult, String> {
let provider = self.resolve_filesystem_provider(plugin_id, provider_id)?;
ensure_provider_capability(plugin_id, &provider, PluginFilesystemCapability::Rename)?;
let source_uri = resolve_filesystem_uri(&provider, Some(source_uri))?;
let target_uri = resolve_filesystem_uri(&provider, Some(target_uri))?;
let request = PluginFilesystemRenameRequest {
provider_id,
connection_id: normalized_optional_value(connection_id),
source_uri: &source_uri,
target_uri: &target_uri,
overwrite,
};
self.invoke_filesystem_mutation(plugin_id, &provider, PLUGIN_FILESYSTEM_RENAME_METHOD, request).await
}
async fn invoke_filesystem_mutation<T: Serialize>(
&self,
plugin_id: &str,
provider: &PluginFilesystemProviderContribution,
method: &str,
request: T,
) -> Result<PluginFilesystemMutationResult, String> {
let result: PluginFilesystemMutationResult = self
.invoke(
plugin_id,
method,
serde_json::to_value(request).map_err(|error| error.to_string())?,
None,
Some(Duration::from_secs(30)),
)
.await?;
validate_mutation_result(provider, &result)?;
Ok(result)
}
fn resolve_filesystem_provider(
&self,
plugin_id: &str,
provider_id: &str,
) -> Result<PluginFilesystemProviderContribution, String> {
let plugin =
self.registry().find_plugin(plugin_id)?.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
if !plugin.compatibility.compatible {
return Err(format!("Plugin '{plugin_id}' is incompatible: {}", plugin.compatibility.errors.join("; ")));
}
plugin
.manifest
.filesystem_provider(provider_id)?
.ok_or_else(|| format!("Filesystem provider '{plugin_id}/{provider_id}' is not declared"))
}
}
fn normalized_optional_value(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn resolve_filesystem_uri(
provider: &PluginFilesystemProviderContribution,
requested_uri: Option<&str>,
) -> Result<String, String> {
let uri = requested_uri
.map(str::trim)
.filter(|uri| !uri.is_empty())
.map(ToOwned::to_owned)
.or_else(|| provider.root_uri.clone())
.unwrap_or_else(|| format!("{}:/", provider.schemes[0]));
if uri.len() > 4_096 || uri.chars().any(char::is_control) {
return Err("Plugin filesystem URI is invalid".to_string());
}
let scheme = uri.split_once(':').map(|(scheme, _)| scheme).unwrap_or_default();
if !provider.schemes.iter().any(|candidate| candidate == scheme) {
return Err(format!("Filesystem URI scheme '{scheme}' is not handled by provider '{}'", provider.id));
}
Ok(uri)
}
fn validate_list_result(
provider: &PluginFilesystemProviderContribution,
result: &PluginFilesystemListResult,
requested_limit: u32,
) -> Result<(), String> {
let maximum_entries = usize::try_from(requested_limit).unwrap_or(usize::MAX);
if result.entries.len() > maximum_entries {
return Err(format!(
"Filesystem provider '{}' returned {} entries for a limit of {requested_limit}",
provider.id,
result.entries.len()
));
}
if result.next_cursor.as_ref().is_some_and(|cursor| cursor.is_empty() || cursor.len() > 4_096) {
return Err(format!("Filesystem provider '{}' returned an invalid cursor", provider.id));
}
for entry in &result.entries {
validate_entry(provider, entry)?;
}
Ok(())
}
fn validate_entry(
provider: &PluginFilesystemProviderContribution,
entry: &PluginFilesystemEntry,
) -> Result<(), String> {
if entry.name.trim().is_empty() || entry.name.len() > 1_024 || entry.name.chars().any(char::is_control) {
return Err(format!("Filesystem provider '{}' returned an invalid entry name", provider.id));
}
resolve_filesystem_uri(provider, Some(&entry.uri))?;
if entry.modified_at.as_ref().is_some_and(|value| value.len() > 128) {
return Err(format!("Filesystem provider '{}' returned an invalid modified timestamp", provider.id));
}
if entry.content_type.as_ref().is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control)) {
return Err(format!("Filesystem provider '{}' returned an invalid content type", provider.id));
}
Ok(())
}
fn validate_read_result(result: &PluginFilesystemReadResult, max_bytes: u64) -> Result<(), String> {
if result.content_type.as_ref().is_some_and(|value| value.len() > 256 || value.chars().any(char::is_control)) {
return Err("Filesystem provider returned an invalid content type".to_string());
}
if result.etag.as_ref().is_some_and(|value| value.len() > 512 || value.chars().any(char::is_control)) {
return Err("Filesystem provider returned an invalid etag".to_string());
}
let estimated_bytes = result.data_base64.len().saturating_mul(3) / 4;
if estimated_bytes as u64 > max_bytes.saturating_add(2) {
return Err(format!("Filesystem provider returned more than the requested {max_bytes} bytes"));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(&result.data_base64)
.map_err(|error| format!("Filesystem provider returned invalid base64 data: {error}"))?;
if decoded.len() as u64 > max_bytes {
return Err(format!("Filesystem provider returned more than the requested {max_bytes} bytes"));
}
Ok(())
}
fn validate_inline_write_data(data_base64: &str) -> Result<(), String> {
let estimated_bytes = data_base64.len().saturating_mul(3) / 4;
if estimated_bytes as u64 > MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES.saturating_add(2) {
return Err(format!(
"Inline plugin filesystem writes are limited to {MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES} bytes"
));
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(data_base64)
.map_err(|error| format!("Plugin filesystem write contains invalid base64 data: {error}"))?;
if decoded.len() as u64 > MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES {
return Err(format!(
"Inline plugin filesystem writes are limited to {MAX_PLUGIN_FILESYSTEM_INLINE_WRITE_BYTES} bytes"
));
}
Ok(())
}
fn validate_mutation_result(
provider: &PluginFilesystemProviderContribution,
result: &PluginFilesystemMutationResult,
) -> Result<(), String> {
if !result.success {
return Err(result.message.clone().unwrap_or_else(|| "Plugin filesystem operation failed".to_string()));
}
if result.message.as_ref().is_some_and(|message| message.len() > 4_096 || message.chars().any(char::is_control)) {
return Err(format!("Filesystem provider '{}' returned an invalid operation message", provider.id));
}
if let Some(entry) = &result.entry {
validate_entry(provider, entry)?;
}
Ok(())
}
fn ensure_provider_capability(
plugin_id: &str,
provider: &PluginFilesystemProviderContribution,
capability: PluginFilesystemCapability,
) -> Result<(), String> {
if provider.has_capability(capability) {
Ok(())
} else {
Err(format!(
"Filesystem provider '{plugin_id}/{}' does not declare {} capability",
provider.id,
capability.as_str()
))
}
}
#[cfg(test)]
mod tests {
use super::{resolve_filesystem_uri, validate_inline_write_data, validate_list_result, validate_read_result};
use crate::plugins::{
PluginFilesystemCapability, PluginFilesystemEntry, PluginFilesystemEntryKind, PluginFilesystemListResult,
PluginFilesystemProviderContribution, PluginFilesystemReadResult,
};
fn provider() -> PluginFilesystemProviderContribution {
PluginFilesystemProviderContribution {
id: "sample.files".to_string(),
label: "Sample files".to_string(),
schemes: vec!["sample".to_string()],
description: None,
capabilities: vec![PluginFilesystemCapability::Read],
root_uri: Some("sample:/home".to_string()),
}
}
#[test]
fn resolves_declared_root_and_rejects_other_schemes() {
assert_eq!(resolve_filesystem_uri(&provider(), None).unwrap(), "sample:/home");
assert!(resolve_filesystem_uri(&provider(), Some("other:/tmp")).unwrap_err().contains("not handled"));
}
#[test]
fn validates_list_limits_and_entry_uris() {
let valid = PluginFilesystemListResult {
entries: vec![PluginFilesystemEntry {
name: "README.txt".to_string(),
uri: "sample:/README.txt".to_string(),
kind: PluginFilesystemEntryKind::File,
size: Some(5),
modified_at: None,
content_type: Some("text/plain".to_string()),
}],
next_cursor: None,
};
validate_list_result(&provider(), &valid, 1).unwrap();
assert!(validate_list_result(&provider(), &valid, 0).unwrap_err().contains("limit of 0"));
}
#[test]
fn rejects_invalid_or_oversized_file_preview() {
let invalid = PluginFilesystemReadResult {
data_base64: "not base64".to_string(),
content_type: None,
truncated: false,
etag: None,
};
assert!(validate_read_result(&invalid, 16).unwrap_err().contains("invalid base64"));
let oversized = PluginFilesystemReadResult {
data_base64: "YWJjZA==".to_string(),
content_type: None,
truncated: false,
etag: None,
};
assert!(validate_read_result(&oversized, 3).unwrap_err().contains("more than"));
}
#[test]
fn validates_inline_write_base64() {
validate_inline_write_data("YWJjZA==").unwrap();
assert!(validate_inline_write_data("not base64").unwrap_err().contains("invalid base64"));
}
}
+702
View File
@@ -0,0 +1,702 @@
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use crate::models::connection::{ConnectionConfig, ConnectionTestResult, DatabaseType};
use serde::de::DeserializeOwned;
use serde::Serialize;
use tokio::sync::{broadcast, Mutex, RwLock};
use super::{
InstalledPlugin, PluginBinaryMessage, PluginConnectionActionContribution, PluginConnectionCapability,
PluginConnectionProviderContribution, PluginEvent, PluginFormFieldBinding, PluginFormFieldDefinition,
PluginFormFieldType, PluginRegistry, PluginRuntimeEnv, PluginSessionState, PluginSidecarSession,
PLUGIN_CONNECTION_ACTION_METHOD, PLUGIN_CONNECTION_CONNECT_METHOD, PLUGIN_CONNECTION_DISCONNECT_METHOD,
PLUGIN_CONNECTION_TEST_METHOD,
};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ActivePluginSession {
pub plugin_id: String,
pub process_id: Option<u32>,
pub state: PluginSessionState,
}
#[derive(Debug, Clone, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginConnectionActionResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub field_values: BTreeMap<String, serde_json::Value>,
}
#[derive(Clone)]
pub struct PluginHost {
inner: Arc<PluginHostInner>,
}
#[derive(Clone)]
pub struct PluginConnectionHandle {
pub plugin_id: String,
pub provider_id: String,
pub connection_id: String,
session: Option<Arc<PluginSidecarSession>>,
disconnect: bool,
params: serde_json::Value,
}
impl PluginConnectionHandle {
pub fn is_running(&self) -> bool {
self.session.as_ref().is_none_or(|session| session.status().state == PluginSessionState::Running)
}
pub async fn disconnect(&self) -> Result<(), String> {
let Some(session) = &self.session else {
return Ok(());
};
if !self.disconnect || session.status().state != PluginSessionState::Running {
return Ok(());
}
let result: serde_json::Value = session
.invoke_with_timeout(
PLUGIN_CONNECTION_DISCONNECT_METHOD,
self.params.clone(),
None,
Some(super::PLUGIN_REQUEST_TIMEOUT),
)
.await?;
ensure_plugin_operation_succeeded(result)
}
}
struct PluginHostInner {
registry: PluginRegistry,
sessions: RwLock<HashMap<String, Arc<PluginSidecarSession>>>,
activation_lock: Mutex<()>,
events: broadcast::Sender<PluginEvent>,
binary_messages: broadcast::Sender<PluginBinaryMessage>,
}
impl PluginHost {
pub fn new(registry: PluginRegistry) -> Self {
let (events, _) = broadcast::channel(512);
let (binary_messages, _) = broadcast::channel(128);
Self {
inner: Arc::new(PluginHostInner {
registry,
sessions: RwLock::new(HashMap::new()),
activation_lock: Mutex::new(()),
events,
binary_messages,
}),
}
}
pub fn subscribe_events(&self) -> broadcast::Receiver<PluginEvent> {
self.inner.events.subscribe()
}
pub fn subscribe_binary(&self) -> broadcast::Receiver<PluginBinaryMessage> {
self.inner.binary_messages.subscribe()
}
pub(super) fn registry(&self) -> &PluginRegistry {
&self.inner.registry
}
pub async fn activate(&self, plugin_id: &str) -> Result<Arc<PluginSidecarSession>, String> {
self.activate_with_env(plugin_id, PluginRuntimeEnv::default()).await
}
pub async fn activate_with_env(
&self,
plugin_id: &str,
env: PluginRuntimeEnv,
) -> Result<Arc<PluginSidecarSession>, String> {
if let Some(session) = self.running_session(plugin_id).await {
return Ok(session);
}
let _activation = self.inner.activation_lock.lock().await;
if let Some(session) = self.running_session(plugin_id).await {
return Ok(session);
}
self.inner.sessions.write().await.remove(plugin_id);
let plugin = self
.inner
.registry
.find_plugin(plugin_id)?
.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
if !plugin.compatibility.compatible {
return Err(format!("Plugin '{plugin_id}' is incompatible: {}", plugin.compatibility.errors.join("; ")));
}
let session = PluginSidecarSession::start(plugin, self.inner.registry.app_version().to_string(), env).await?;
self.forward_session_events(&session);
self.inner.sessions.write().await.insert(plugin_id.to_string(), session.clone());
Ok(session)
}
pub async fn invoke<T>(
&self,
plugin_id: &str,
method: &str,
params: serde_json::Value,
required_permission: Option<&str>,
timeout: Option<Duration>,
) -> Result<T, String>
where
T: DeserializeOwned,
{
let session = self.activate(plugin_id).await?;
ensure_permission(session.plugin(), required_permission)?;
session.invoke_with_timeout(method, params, None, timeout).await
}
pub async fn notify(
&self,
plugin_id: &str,
method: &str,
params: serde_json::Value,
required_permission: Option<&str>,
) -> Result<(), String> {
let session = self.activate(plugin_id).await?;
ensure_permission(session.plugin(), required_permission)?;
session.notify(method, params, None).await
}
pub async fn send_binary(
&self,
plugin_id: &str,
channel: &str,
data: &[u8],
required_permission: Option<&str>,
) -> Result<(), String> {
let session = self.activate(plugin_id).await?;
ensure_permission(session.plugin(), required_permission)?;
session.send_binary(channel, data).await
}
pub async fn test_connection(
&self,
config: &ConnectionConfig,
runtime_host: &str,
runtime_port: u16,
) -> Result<ConnectionTestResult, String> {
let (plugin, provider) = self.resolve_connection_provider(config)?;
validate_plugin_connection_values(config, &provider)?;
let provider_label = provider.label.as_deref().unwrap_or(&plugin.manifest.name);
if !provider.has_capability(PluginConnectionCapability::Test) {
return Ok(ConnectionTestResult::success(format!("{provider_label} is available")));
}
let session = self.activate(config.plugin_id.as_deref().unwrap_or_default()).await?;
let result: serde_json::Value = session
.invoke_with_timeout(
PLUGIN_CONNECTION_TEST_METHOD,
plugin_connection_params(config, &provider, runtime_host, runtime_port)?,
None,
Some(Duration::from_secs(config.effective_connect_timeout_secs())),
)
.await?;
plugin_connection_test_result(result, provider_label)
}
pub async fn connect_connection(
&self,
config: &ConnectionConfig,
runtime_host: &str,
runtime_port: u16,
) -> Result<PluginConnectionHandle, String> {
let (_, provider) = self.resolve_connection_provider(config)?;
validate_plugin_connection_values(config, &provider)?;
let params = plugin_connection_params(config, &provider, runtime_host, runtime_port)?;
let needs_session = provider.has_capability(PluginConnectionCapability::Connect)
|| provider.has_capability(PluginConnectionCapability::Disconnect);
let session = if needs_session {
Some(self.activate(config.plugin_id.as_deref().unwrap_or_default()).await?)
} else {
None
};
if provider.has_capability(PluginConnectionCapability::Connect) {
if let Some(session) = &session {
let result: serde_json::Value = session
.invoke_with_timeout(
PLUGIN_CONNECTION_CONNECT_METHOD,
params.clone(),
None,
Some(Duration::from_secs(config.effective_connect_timeout_secs())),
)
.await?;
ensure_plugin_operation_succeeded(result)?;
}
}
let disconnect = provider.has_capability(PluginConnectionCapability::Disconnect);
Ok(PluginConnectionHandle {
plugin_id: config.plugin_id.clone().unwrap_or_default(),
provider_id: provider.id,
connection_id: config.id.clone(),
session,
disconnect,
params,
})
}
pub async fn invoke_connection_action(
&self,
config: &ConnectionConfig,
action_id: &str,
runtime_host: &str,
runtime_port: u16,
) -> Result<PluginConnectionActionResult, String> {
let (_, provider) = self.resolve_connection_provider(config)?;
let action = plugin_invoke_connection_action(&provider, action_id)?;
validate_plugin_connection_values_for_action(config, &provider, action.requires_valid_form)?;
let session = self.activate(config.plugin_id.as_deref().unwrap_or_default()).await?;
let mut params = plugin_connection_params(config, &provider, runtime_host, runtime_port)?;
params
.as_object_mut()
.ok_or("Plugin connection action params must be an object")?
.insert("action".to_string(), serde_json::json!({ "id": action.id }));
let result: serde_json::Value = session
.invoke_with_timeout(
PLUGIN_CONNECTION_ACTION_METHOD,
params,
None,
action.timeout_ms.map(Duration::from_millis).or(Some(super::PLUGIN_REQUEST_TIMEOUT)),
)
.await?;
plugin_connection_action_result(result, &provider)
}
pub async fn list_active(&self) -> Vec<ActivePluginSession> {
let sessions = self.inner.sessions.read().await.values().cloned().collect::<Vec<_>>();
let mut active = Vec::with_capacity(sessions.len());
for session in sessions {
active.push(ActivePluginSession {
plugin_id: session.plugin().manifest.id.clone(),
process_id: session.pid().await,
state: session.status().state,
});
}
active.sort_by(|left, right| left.plugin_id.cmp(&right.plugin_id));
active
}
pub async fn stop(&self, plugin_id: &str) {
if let Some(session) = self.inner.sessions.write().await.remove(plugin_id) {
session.shutdown().await;
}
}
pub async fn stop_all(&self) {
let sessions = std::mem::take(&mut *self.inner.sessions.write().await);
for (_, session) in sessions {
session.shutdown().await;
}
}
async fn running_session(&self, plugin_id: &str) -> Option<Arc<PluginSidecarSession>> {
let session = self.inner.sessions.read().await.get(plugin_id).cloned()?;
(session.status().state == PluginSessionState::Running).then_some(session)
}
fn resolve_connection_provider(
&self,
config: &ConnectionConfig,
) -> Result<(InstalledPlugin, PluginConnectionProviderContribution), String> {
if config.db_type != DatabaseType::Plugin {
return Err("Connection is not a plugin-owned connection".to_string());
}
let plugin_id = required_plugin_binding(&config.plugin_id, "plugin_id")?;
let provider_id = required_plugin_binding(&config.plugin_connection_provider, "plugin_connection_provider")?;
let connection_type = required_plugin_binding(&config.plugin_connection_type, "plugin_connection_type")?;
let plugin = self
.inner
.registry
.find_plugin(plugin_id)?
.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
if !plugin.compatibility.compatible {
return Err(format!("Plugin '{plugin_id}' is incompatible: {}", plugin.compatibility.errors.join("; ")));
}
let provider = plugin
.manifest
.connection_provider(provider_id)?
.ok_or_else(|| format!("Plugin '{plugin_id}' does not provide connection provider '{provider_id}'"))?;
if provider.database_type != connection_type {
return Err(format!(
"Connection type '{}' does not match provider '{}' type '{}'",
connection_type, provider.id, provider.database_type
));
}
Ok((plugin, provider))
}
fn forward_session_events(&self, session: &Arc<PluginSidecarSession>) {
let mut events = session.subscribe_events();
let host_events = self.inner.events.clone();
tokio::spawn(async move {
loop {
match events.recv().await {
Ok(event) => {
let _ = host_events.send(event);
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!("Plugin host event relay skipped {skipped} events");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
let mut binary = session.subscribe_binary();
let host_binary = self.inner.binary_messages.clone();
tokio::spawn(async move {
loop {
match binary.recv().await {
Ok(message) => {
let _ = host_binary.send(message);
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!("Plugin host binary relay skipped {skipped} messages");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
}
fn required_plugin_binding<'a>(value: &'a Option<String>, field: &str) -> Result<&'a str, String> {
value
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| format!("Plugin connection is missing {field}"))
}
fn plugin_connection_params(
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
runtime_host: &str,
runtime_port: u16,
) -> Result<serde_json::Value, String> {
Ok(serde_json::json!({
"provider": {
"id": provider.id,
"databaseType": provider.database_type,
},
"connection": serde_json::to_value(config).map_err(|error| error.to_string())?,
"runtime": {
"host": runtime_host,
"port": runtime_port,
}
}))
}
fn validate_plugin_connection_values(
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
) -> Result<(), String> {
validate_plugin_connection_values_for_action(config, provider, true)
}
fn plugin_invoke_connection_action<'a>(
provider: &'a PluginConnectionProviderContribution,
action_id: &str,
) -> Result<&'a PluginConnectionActionContribution, String> {
provider
.actions
.iter()
.find(|action| action.id == action_id)
.ok_or_else(|| format!("Connection provider '{}' does not declare action '{action_id}'", provider.id))
}
fn validate_plugin_connection_values_for_action(
config: &ConnectionConfig,
provider: &PluginConnectionProviderContribution,
require_required_fields: bool,
) -> Result<(), String> {
let allowed_secrets = provider
.fields
.iter()
.filter(|field| field.effective_binding() == PluginFormFieldBinding::Secret)
.map(|field| field.key.as_str())
.collect::<HashSet<_>>();
for key in config.connection_secrets.keys() {
if !allowed_secrets.contains(key.as_str()) {
return Err(format!("Connection secret '{key}' is not declared by provider '{}'", provider.id));
}
}
for field in &provider.fields {
let value = plugin_connection_field_value(config, field);
if require_required_fields && field.required && plugin_field_value_is_empty(value.as_ref()) {
return Err(format!("Plugin connection field '{}' is required", field.label));
}
if let Some(value) = value {
validate_plugin_field_type(field, &value)?;
if field.effective_binding() == PluginFormFieldBinding::Port
&& value.as_u64().is_none_or(|port| port == 0 || port > u16::MAX as u64)
{
return Err(format!("Plugin connection field '{}' must be a port between 1 and 65535", field.label));
}
}
}
Ok(())
}
fn plugin_connection_action_result(
result: serde_json::Value,
provider: &PluginConnectionProviderContribution,
) -> Result<PluginConnectionActionResult, String> {
if let Some(message) = result.as_str() {
return Ok(PluginConnectionActionResult { message: Some(message.to_string()), field_values: BTreeMap::new() });
}
if result.is_null() {
return Ok(PluginConnectionActionResult::default());
}
let object = result
.as_object()
.ok_or_else(|| "Plugin connection action must return null, a message string, or an object".to_string())?;
if object.get("success").and_then(serde_json::Value::as_bool) == Some(false) {
return Err(object
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Plugin connection action failed")
.to_string());
}
let message = match object.get("message") {
None | Some(serde_json::Value::Null) => None,
Some(serde_json::Value::String(message)) => Some(message.clone()),
Some(_) => return Err("Plugin connection action message must be a string".to_string()),
};
let mut field_values = BTreeMap::new();
if let Some(values) = object.get("fieldValues") {
let values =
values.as_object().ok_or_else(|| "Plugin connection action fieldValues must be an object".to_string())?;
for (key, value) in values {
let field = provider
.fields
.iter()
.find(|field| field.key == *key)
.ok_or_else(|| format!("Plugin connection action returned undeclared field '{key}'"))?;
if !value.is_null() {
validate_plugin_field_type(field, value)?;
if field.effective_binding() == PluginFormFieldBinding::Port
&& value.as_u64().is_none_or(|port| port == 0 || port > u16::MAX as u64)
{
return Err(format!(
"Plugin connection field '{}' must be a port between 1 and 65535",
field.label
));
}
}
field_values.insert(key.clone(), value.clone());
}
}
Ok(PluginConnectionActionResult { message, field_values })
}
fn plugin_connection_field_value(
config: &ConnectionConfig,
field: &PluginFormFieldDefinition,
) -> Option<serde_json::Value> {
match field.effective_binding() {
PluginFormFieldBinding::Name => Some(serde_json::Value::String(config.name.clone())),
PluginFormFieldBinding::Host => Some(serde_json::Value::String(config.host.clone())),
PluginFormFieldBinding::Port => Some(serde_json::Value::Number(config.port.into())),
PluginFormFieldBinding::Username => Some(serde_json::Value::String(config.username.clone())),
PluginFormFieldBinding::Password => Some(serde_json::Value::String(config.password.clone())),
PluginFormFieldBinding::Database => config.database.clone().map(serde_json::Value::String),
PluginFormFieldBinding::Secret => {
config.connection_secrets.get(&field.key).cloned().map(serde_json::Value::String)
}
PluginFormFieldBinding::Config => config.external_config.as_ref()?.get(&field.key).cloned(),
}
}
fn plugin_field_value_is_empty(value: Option<&serde_json::Value>) -> bool {
match value {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(value)) => value.trim().is_empty(),
_ => false,
}
}
fn validate_plugin_field_type(field: &PluginFormFieldDefinition, value: &serde_json::Value) -> Result<(), String> {
let valid = match field.field_type {
PluginFormFieldType::Text
| PluginFormFieldType::Password
| PluginFormFieldType::Select
| PluginFormFieldType::Radio
| PluginFormFieldType::Textarea => value.is_string(),
PluginFormFieldType::Number => value.is_number(),
PluginFormFieldType::Boolean => value.is_boolean(),
};
if valid {
Ok(())
} else {
Err(format!("Plugin connection field '{}' has an invalid value type", field.label))
}
}
fn plugin_connection_test_result(
result: serde_json::Value,
provider_label: &str,
) -> Result<ConnectionTestResult, String> {
if let Some(success) = result.get("success").and_then(serde_json::Value::as_bool) {
let message = result.get("message").and_then(serde_json::Value::as_str).unwrap_or(if success {
"Connection successful"
} else {
"Connection failed"
});
if !success {
return Err(message.to_string());
}
return Ok(ConnectionTestResult::success(message));
}
if let Some(message) = result.as_str() {
return Ok(ConnectionTestResult::success(message));
}
if result.is_null() {
return Ok(ConnectionTestResult::success(format!("{provider_label} connection successful")));
}
serde_json::from_value(result)
.map_err(|error| format!("Plugin connection test returned an invalid result: {error}"))
}
fn ensure_plugin_operation_succeeded(result: serde_json::Value) -> Result<(), String> {
if result.get("success").and_then(serde_json::Value::as_bool) == Some(false) {
return Err(result
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Plugin connection operation failed")
.to_string());
}
Ok(())
}
fn ensure_permission(plugin: &super::InstalledPlugin, required_permission: Option<&str>) -> Result<(), String> {
let Some(permission) = required_permission else {
return Ok(());
};
if plugin.manifest.permissions.iter().any(|declared| declared == permission) {
return Ok(());
}
Err(format!("Plugin '{}' has not declared permission '{permission}'", plugin.manifest.id))
}
#[cfg(test)]
mod tests {
use super::{
plugin_connection_action_result, plugin_invoke_connection_action, validate_plugin_connection_values,
validate_plugin_connection_values_for_action,
};
use crate::models::connection::ConnectionConfig;
use crate::plugins::PluginConnectionProviderContribution;
#[test]
fn rejects_zero_port_for_port_bound_connection_field() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "plugin-connection",
"name": "Plugin connection",
"db_type": "plugin",
"host": "localhost",
"port": 0,
"username": "",
"password": "",
"database": null,
"plugin_id": "sample",
"plugin_connection_provider": "sample.connection",
"plugin_connection_type": "sample"
}))
.unwrap();
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"label": "Sample",
"database_type": "sample",
"fields": [{ "key": "port", "label": "Port", "type": "number", "binding": "port", "required": true }]
}))
.unwrap();
assert!(validate_plugin_connection_values(&config, &provider)
.unwrap_err()
.contains("port between 1 and 65535"));
}
#[test]
fn allows_incomplete_fields_only_for_declared_permissive_actions() {
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
"id": "plugin-connection",
"name": "Plugin connection",
"db_type": "plugin",
"host": "",
"port": 0,
"username": "",
"password": "",
"database": null,
"plugin_id": "sample",
"plugin_connection_provider": "sample.connection",
"plugin_connection_type": "sample"
}))
.unwrap();
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"label": "Sample",
"database_type": "sample",
"fields": [{ "key": "host", "label": "Host", "type": "text", "binding": "host", "required": true }]
}))
.unwrap();
assert!(validate_plugin_connection_values_for_action(&config, &provider, false).is_ok());
assert!(validate_plugin_connection_values_for_action(&config, &provider, true)
.unwrap_err()
.contains("is required"));
}
#[test]
fn resolves_only_declared_custom_actions() {
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"label": "Sample",
"database_type": "sample",
"fields": [],
"actions": [{ "id": "discover", "label": "Discover" }]
}))
.unwrap();
assert_eq!(plugin_invoke_connection_action(&provider, "discover").unwrap().id, "discover");
assert!(plugin_invoke_connection_action(&provider, "missing").unwrap_err().contains("does not declare action"));
}
#[test]
fn validates_connection_action_field_updates() {
let provider: PluginConnectionProviderContribution = serde_json::from_value(serde_json::json!({
"id": "sample.connection",
"label": "Sample",
"database_type": "sample",
"fields": [
{ "key": "host", "label": "Host", "type": "text", "binding": "host" },
{ "key": "port", "label": "Port", "type": "number", "binding": "port" }
]
}))
.unwrap();
let result = plugin_connection_action_result(
serde_json::json!({ "message": "Updated", "fieldValues": { "host": "db.internal", "port": 5432 } }),
&provider,
)
.unwrap();
assert_eq!(result.message.as_deref(), Some("Updated"));
assert_eq!(result.field_values.get("port").and_then(serde_json::Value::as_u64), Some(5432));
assert!(plugin_connection_action_result(
serde_json::json!({ "fieldValues": { "unknown": "value" } }),
&provider,
)
.unwrap_err()
.contains("undeclared field"));
assert!(plugin_connection_action_result(serde_json::json!({ "fieldValues": { "port": "5432" } }), &provider,)
.unwrap_err()
.contains("invalid value type"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+912
View File
@@ -0,0 +1,912 @@
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use bytes::Bytes;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::sync::{broadcast, oneshot, watch, Mutex, RwLock};
use tokio::time::timeout;
use super::{
InstalledPlugin, PluginBackendTransport, PluginRuntimeEnv, SUPPORTED_PLUGIN_HOST_API_VERSION,
SUPPORTED_PLUGIN_PROTOCOL_VERSION,
};
pub const PLUGIN_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_JSON_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
const MAX_BINARY_MESSAGE_BYTES: usize = 64 * 1024 * 1024;
const FRAME_KIND_JSON: u8 = 0;
const FRAME_KIND_BINARY: u8 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PluginEvent {
pub plugin_id: String,
pub method: String,
#[serde(default)]
pub params: serde_json::Value,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginBinaryMessage {
pub plugin_id: String,
pub channel: String,
pub data: Bytes,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PluginSessionState {
Starting,
Running,
Stopping,
Stopped,
Exited,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginSessionStatus {
pub state: PluginSessionState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
impl PluginSessionStatus {
fn new(state: PluginSessionState, message: Option<String>) -> Self {
Self { state, message }
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PluginHandshake {
pub protocol_version: u32,
#[serde(default)]
pub capabilities: Vec<String>,
pub plugin: PluginHandshakeIdentity,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct PluginHandshakeIdentity {
pub id: String,
pub version: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginInitializeParams<'a> {
host: PluginHostDescription<'a>,
plugin: PluginDescription<'a>,
permissions: &'a [String],
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginHostDescription<'a> {
dbx_version: &'a str,
host_api_version: &'static str,
protocol_versions: [u32; 1],
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct PluginDescription<'a> {
id: &'a str,
version: &'a str,
}
#[derive(Debug, Serialize)]
struct PluginRequest<'a> {
jsonrpc: &'static str,
id: u64,
#[serde(skip_serializing_if = "Option::is_none")]
driver: Option<&'a str>,
method: &'a str,
params: serde_json::Value,
}
#[derive(Debug, Serialize)]
struct PluginNotification<'a> {
jsonrpc: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
driver: Option<&'a str>,
method: &'a str,
params: serde_json::Value,
}
type PendingResponse = oneshot::Sender<Result<serde_json::Value, String>>;
pub struct PluginSidecarSession {
plugin: InstalledPlugin,
app_version: String,
transport: PluginBackendTransport,
child: Arc<Mutex<Child>>,
stdin: Mutex<ChildStdin>,
pending: Arc<Mutex<HashMap<u64, PendingResponse>>>,
next_request_id: AtomicU64,
events: broadcast::Sender<PluginEvent>,
binary_messages: broadcast::Sender<PluginBinaryMessage>,
status: watch::Sender<PluginSessionStatus>,
handshake: RwLock<Option<PluginHandshake>>,
}
impl PluginSidecarSession {
pub async fn start(
plugin: InstalledPlugin,
app_version: impl Into<String>,
env: PluginRuntimeEnv,
) -> Result<Arc<Self>, String> {
ensure_plugin_backend(&plugin)?;
let transport = plugin.manifest.backend_entrypoint().map(|backend| backend.transport).unwrap_or_default();
let app_version = app_version.into();
let mut child = spawn_plugin_child(&plugin, &app_version, &env)?;
let stdin = child.stdin.take().ok_or("Plugin stdin unavailable")?;
let stdout = child.stdout.take().ok_or("Plugin stdout unavailable")?;
let stderr = child.stderr.take();
let child = Arc::new(Mutex::new(child));
let pending = Arc::new(Mutex::new(HashMap::new()));
let (events, _) = broadcast::channel(256);
let (binary_messages, _) = broadcast::channel(64);
let (status, _) = watch::channel(PluginSessionStatus::new(PluginSessionState::Starting, None));
let session = Arc::new(Self {
plugin,
app_version,
transport,
child,
stdin: Mutex::new(stdin),
pending,
next_request_id: AtomicU64::new(1),
events,
binary_messages,
status,
handshake: RwLock::new(None),
});
session.spawn_stdout_reader(stdout);
if let Some(stderr) = stderr {
session.spawn_stderr_reader(stderr);
}
if !session.plugin.manifest.is_legacy() {
let handshake = match session.initialize().await {
Ok(handshake) => handshake,
Err(error) => {
session.shutdown().await;
return Err(format!("Plugin '{}' initialization failed: {error}", session.plugin.manifest.id));
}
};
*session.handshake.write().await = Some(handshake);
}
let transitioned = session.status.send_if_modified(|status| {
if status.state != PluginSessionState::Starting {
return false;
}
*status = PluginSessionStatus::new(PluginSessionState::Running, None);
true
});
if !transitioned {
let status = session.status();
session.shutdown().await;
return Err(format!(
"Plugin '{}' stopped during initialization{}",
session.plugin.manifest.id,
status.message.map_or_else(String::new, |message| format!(": {message}"))
));
}
Ok(session)
}
pub fn plugin(&self) -> &InstalledPlugin {
&self.plugin
}
pub fn subscribe_events(&self) -> broadcast::Receiver<PluginEvent> {
self.events.subscribe()
}
pub fn subscribe_binary(&self) -> broadcast::Receiver<PluginBinaryMessage> {
self.binary_messages.subscribe()
}
pub fn subscribe_status(&self) -> watch::Receiver<PluginSessionStatus> {
self.status.subscribe()
}
pub fn status(&self) -> PluginSessionStatus {
self.status.borrow().clone()
}
pub async fn handshake(&self) -> Option<PluginHandshake> {
self.handshake.read().await.clone()
}
pub async fn invoke<T>(&self, method: &str, params: serde_json::Value) -> Result<T, String>
where
T: DeserializeOwned,
{
self.invoke_with_timeout(method, params, None, Some(PLUGIN_REQUEST_TIMEOUT)).await
}
pub async fn invoke_with_timeout<T>(
&self,
method: &str,
params: serde_json::Value,
driver: Option<&str>,
timeout_duration: Option<Duration>,
) -> Result<T, String>
where
T: DeserializeOwned,
{
let value = self.invoke_value(method, params, driver, timeout_duration).await?;
serde_json::from_value(value)
.map_err(|error| format!("Failed to decode plugin '{}' result: {error}", self.plugin.manifest.id))
}
pub async fn notify(&self, method: &str, params: serde_json::Value, driver: Option<&str>) -> Result<(), String> {
self.ensure_running()?;
validate_protocol_name(method)?;
let notification = PluginNotification { jsonrpc: "2.0", driver, method, params };
let payload = serde_json::to_vec(&notification).map_err(|error| error.to_string())?;
self.write_json(&payload).await
}
pub async fn send_binary(&self, channel: &str, data: &[u8]) -> Result<(), String> {
self.ensure_running()?;
if self.transport != PluginBackendTransport::StdioFramed {
return Err(format!(
"Plugin '{}' does not use the framed transport required for binary messages",
self.plugin.manifest.id
));
}
validate_binary_channel(channel)?;
if data.len() > MAX_BINARY_MESSAGE_BYTES {
return Err(format!("Plugin binary message exceeds {MAX_BINARY_MESSAGE_BYTES} bytes"));
}
let channel_bytes = channel.as_bytes();
let payload_len = 2usize
.checked_add(channel_bytes.len())
.and_then(|length| length.checked_add(data.len()))
.ok_or("Plugin binary frame is too large")?;
let mut stdin = self.stdin.lock().await;
stdin.write_u8(FRAME_KIND_BINARY).await.map_err(|error| self.write_error(error))?;
stdin.write_u32(payload_len as u32).await.map_err(|error| self.write_error(error))?;
stdin.write_u16(channel_bytes.len() as u16).await.map_err(|error| self.write_error(error))?;
stdin.write_all(channel_bytes).await.map_err(|error| self.write_error(error))?;
stdin.write_all(data).await.map_err(|error| self.write_error(error))?;
stdin.flush().await.map_err(|error| self.write_error(error))
}
pub async fn shutdown(&self) {
self.status.send_replace(PluginSessionStatus::new(PluginSessionState::Stopping, None));
let kill_result = self.child.lock().await.kill().await;
let message = kill_result.err().map(|error| error.to_string());
fail_pending(&self.pending, "Plugin session stopped").await;
self.status.send_replace(PluginSessionStatus::new(PluginSessionState::Stopped, message));
}
pub async fn pid(&self) -> Option<u32> {
self.child.lock().await.id()
}
async fn initialize(&self) -> Result<PluginHandshake, String> {
let params = PluginInitializeParams {
host: PluginHostDescription {
dbx_version: &self.app_version,
host_api_version: SUPPORTED_PLUGIN_HOST_API_VERSION,
protocol_versions: [SUPPORTED_PLUGIN_PROTOCOL_VERSION],
},
plugin: PluginDescription { id: &self.plugin.manifest.id, version: &self.plugin.manifest.version },
permissions: &self.plugin.manifest.permissions,
};
let params = serde_json::to_value(params).map_err(|error| error.to_string())?;
let handshake: PluginHandshake =
self.invoke_with_timeout("plugin/initialize", params, None, Some(PLUGIN_REQUEST_TIMEOUT)).await?;
if handshake.protocol_version != SUPPORTED_PLUGIN_PROTOCOL_VERSION {
return Err(format!(
"Plugin selected protocol version {}, expected {}",
handshake.protocol_version, SUPPORTED_PLUGIN_PROTOCOL_VERSION
));
}
if handshake.plugin.id != self.plugin.manifest.id || handshake.plugin.version != self.plugin.manifest.version {
return Err(format!(
"Plugin backend identity '{}/{}' does not match manifest '{}/{}'",
handshake.plugin.id, handshake.plugin.version, self.plugin.manifest.id, self.plugin.manifest.version
));
}
Ok(handshake)
}
async fn invoke_value(
&self,
method: &str,
params: serde_json::Value,
driver: Option<&str>,
timeout_duration: Option<Duration>,
) -> Result<serde_json::Value, String> {
self.ensure_running_or_starting()?;
validate_protocol_name(method)?;
let request_id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let request = PluginRequest { jsonrpc: "2.0", id: request_id, driver, method, params };
let payload = serde_json::to_vec(&request).map_err(|error| error.to_string())?;
let (sender, receiver) = oneshot::channel();
self.pending.lock().await.insert(request_id, sender);
if let Err(error) = self.write_json(&payload).await {
self.pending.lock().await.remove(&request_id);
return Err(error);
}
let receive = async {
receiver.await.map_err(|_| format!("Plugin '{}' response channel closed", self.plugin.manifest.id))?
};
match timeout_duration {
Some(duration) => match timeout(duration, receive).await {
Ok(result) => result,
Err(_) => {
self.pending.lock().await.remove(&request_id);
Err(format!(
"Plugin '{}' request '{}' timed out after {} seconds",
self.plugin.manifest.id,
method,
duration.as_secs()
))
}
},
None => receive.await,
}
}
async fn write_json(&self, payload: &[u8]) -> Result<(), String> {
if payload.len() > MAX_JSON_MESSAGE_BYTES {
return Err(format!("Plugin JSON message exceeds {MAX_JSON_MESSAGE_BYTES} bytes"));
}
let mut stdin = self.stdin.lock().await;
match self.transport {
PluginBackendTransport::StdioJsonLines => {
stdin.write_all(payload).await.map_err(|error| self.write_error(error))?;
stdin.write_u8(b'\n').await.map_err(|error| self.write_error(error))?;
}
PluginBackendTransport::StdioFramed => {
stdin.write_u8(FRAME_KIND_JSON).await.map_err(|error| self.write_error(error))?;
stdin.write_u32(payload.len() as u32).await.map_err(|error| self.write_error(error))?;
stdin.write_all(payload).await.map_err(|error| self.write_error(error))?;
}
}
stdin.flush().await.map_err(|error| self.write_error(error))
}
fn write_error(&self, error: std::io::Error) -> String {
format!("Failed to write to plugin '{}': {error}", self.plugin.manifest.id)
}
fn ensure_running(&self) -> Result<(), String> {
let status = self.status();
if status.state == PluginSessionState::Running {
Ok(())
} else {
Err(format!("Plugin '{}' is not running ({:?})", self.plugin.manifest.id, status.state))
}
}
fn ensure_running_or_starting(&self) -> Result<(), String> {
let status = self.status();
if matches!(status.state, PluginSessionState::Starting | PluginSessionState::Running) {
Ok(())
} else {
Err(format!("Plugin '{}' is not available ({:?})", self.plugin.manifest.id, status.state))
}
}
fn spawn_stdout_reader(self: &Arc<Self>, stdout: ChildStdout) {
let session = self.clone();
tokio::spawn(async move {
let result = match session.transport {
PluginBackendTransport::StdioJsonLines => read_json_lines(&session, BufReader::new(stdout)).await,
PluginBackendTransport::StdioFramed => read_framed(&session, stdout).await,
};
let message = match result {
Ok(()) => session.exit_message().await,
Err(error) => error,
};
fail_pending(&session.pending, &message).await;
if matches!(session.status().state, PluginSessionState::Stopping | PluginSessionState::Stopped) {
return;
}
let message = session.terminate_after_output_end(message).await;
session.status.send_if_modified(|status| {
if matches!(status.state, PluginSessionState::Stopping | PluginSessionState::Stopped) {
return false;
}
*status = PluginSessionStatus::new(PluginSessionState::Exited, Some(message.clone()));
true
});
});
}
fn spawn_stderr_reader(self: &Arc<Self>, stderr: tokio::process::ChildStderr) {
let plugin_id = self.plugin.manifest.id.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stderr);
loop {
match read_limited_line(&mut reader, MAX_JSON_MESSAGE_BYTES).await {
Ok(Some(line)) => log::warn!("[plugin:{plugin_id}] {}", String::from_utf8_lossy(&line).trim_end()),
Ok(None) => break,
Err(error) => {
log::warn!("[plugin:{plugin_id}] failed to read stderr: {error}");
break;
}
}
}
});
}
async fn exit_message(&self) -> String {
match self.child.lock().await.try_wait() {
Ok(Some(status)) => format!("Plugin '{}' exited with status {status}", self.plugin.manifest.id),
Ok(None) => format!("Plugin '{}' closed its output stream", self.plugin.manifest.id),
Err(error) => format!("Plugin '{}' output closed: {error}", self.plugin.manifest.id),
}
}
async fn terminate_after_output_end(&self, message: String) -> String {
let mut child = self.child.lock().await;
match child.try_wait() {
Ok(Some(_)) => message,
Ok(None) => match child.kill().await {
Ok(()) => format!("{message}; process terminated by host"),
Err(error) => format!("{message}; failed to terminate process: {error}"),
},
Err(error) => format!("{message}; failed to inspect process: {error}"),
}
}
async fn dispatch_json(&self, payload: &[u8]) -> Result<(), String> {
let value: serde_json::Value = serde_json::from_slice(payload).map_err(|error| {
format!("Failed to parse plugin '{}' protocol message: {error}", self.plugin.manifest.id)
})?;
if !self.plugin.manifest.is_legacy() && value.get("jsonrpc").and_then(serde_json::Value::as_str) != Some("2.0")
{
return Err(format!("Plugin '{}' sent a message without jsonrpc 2.0", self.plugin.manifest.id));
}
if let Some(request_id) = value.get("id").and_then(serde_json::Value::as_u64) {
let result = decode_response_value(&self.plugin.manifest.id, value);
if let Some(sender) = self.pending.lock().await.remove(&request_id) {
let _ = sender.send(result);
} else {
log::warn!("[plugin:{}] ignored response for unknown request {request_id}", self.plugin.manifest.id);
}
return Ok(());
}
if let Some(method) = value.get("method").and_then(serde_json::Value::as_str) {
if !self.plugin.manifest.is_legacy() {
validate_protocol_name(method)?;
}
let event = PluginEvent {
plugin_id: self.plugin.manifest.id.clone(),
method: method.to_string(),
params: value.get("params").cloned().unwrap_or(serde_json::Value::Null),
};
let _ = self.events.send(event);
return Ok(());
}
Err(format!("Plugin '{}' sent a protocol message without id or method", self.plugin.manifest.id))
}
fn dispatch_binary(&self, payload: Bytes) -> Result<(), String> {
if payload.len() < 2 {
return Err(format!("Plugin '{}' sent an invalid binary frame", self.plugin.manifest.id));
}
let channel_len = u16::from_be_bytes([payload[0], payload[1]]) as usize;
if channel_len == 0 || payload.len() < 2 + channel_len {
return Err(format!("Plugin '{}' sent an invalid binary channel", self.plugin.manifest.id));
}
let channel = std::str::from_utf8(&payload[2..2 + channel_len]).map_err(|error| {
format!("Plugin '{}' sent a non-UTF-8 binary channel: {error}", self.plugin.manifest.id)
})?;
validate_binary_channel(channel)?;
let message = PluginBinaryMessage {
plugin_id: self.plugin.manifest.id.clone(),
channel: channel.to_string(),
data: payload.slice(2 + channel_len..),
};
let _ = self.binary_messages.send(message);
Ok(())
}
}
async fn read_json_lines(session: &PluginSidecarSession, mut reader: BufReader<ChildStdout>) -> Result<(), String> {
loop {
let Some(line) = read_limited_line(&mut reader, MAX_JSON_MESSAGE_BYTES)
.await
.map_err(|error| format!("Failed to read plugin '{}' output: {error}", session.plugin.manifest.id))?
else {
return Ok(());
};
let trimmed = trim_ascii_whitespace(&line);
if trimmed.is_empty() {
continue;
}
if let Err(error) = session.dispatch_json(trimmed).await {
if !session.plugin.manifest.is_legacy() {
return Err(error);
}
log::warn!(
"[plugin:{}] ignored non-protocol stdout: {} ({error})",
session.plugin.manifest.id,
String::from_utf8_lossy(trimmed)
);
}
}
}
async fn read_framed(session: &PluginSidecarSession, mut stdout: ChildStdout) -> Result<(), String> {
loop {
let kind = match stdout.read_u8().await {
Ok(kind) => kind,
Err(error) if error.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(()),
Err(error) => {
return Err(format!("Failed to read plugin '{}' frame kind: {error}", session.plugin.manifest.id))
}
};
let length =
stdout.read_u32().await.map_err(|error| {
format!("Failed to read plugin '{}' frame length: {error}", session.plugin.manifest.id)
})? as usize;
let maximum = if kind == FRAME_KIND_JSON { MAX_JSON_MESSAGE_BYTES } else { MAX_BINARY_MESSAGE_BYTES + 1024 };
if length > maximum {
return Err(format!("Plugin '{}' frame exceeds {maximum} bytes", session.plugin.manifest.id));
}
let mut payload = vec![0; length];
stdout.read_exact(&mut payload).await.map_err(|error| {
format!("Failed to read plugin '{}' frame payload: {error}", session.plugin.manifest.id)
})?;
match kind {
FRAME_KIND_JSON => session.dispatch_json(&payload).await?,
FRAME_KIND_BINARY => session.dispatch_binary(Bytes::from(payload))?,
_ => return Err(format!("Plugin '{}' sent unknown frame kind {kind}", session.plugin.manifest.id)),
}
}
}
async fn read_limited_line<R>(reader: &mut R, maximum: usize) -> std::io::Result<Option<Vec<u8>>>
where
R: AsyncBufRead + Unpin,
{
let mut output = Vec::new();
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
return if output.is_empty() { Ok(None) } else { Ok(Some(output)) };
}
let take = available.iter().position(|byte| *byte == b'\n').map(|index| index + 1).unwrap_or(available.len());
if output.len().saturating_add(take) > maximum {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "plugin output line is too large"));
}
output.extend_from_slice(&available[..take]);
reader.consume(take);
if output.last() == Some(&b'\n') {
return Ok(Some(output));
}
}
}
fn trim_ascii_whitespace(mut bytes: &[u8]) -> &[u8] {
while bytes.first().is_some_and(u8::is_ascii_whitespace) {
bytes = &bytes[1..];
}
while bytes.last().is_some_and(u8::is_ascii_whitespace) {
bytes = &bytes[..bytes.len() - 1];
}
bytes
}
fn decode_response_value(plugin_id: &str, value: serde_json::Value) -> Result<serde_json::Value, String> {
if let Some(error) = value.get("error") {
let message = error
.get("message")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| error.to_string());
return Err(message);
}
if value.get("result").is_none() && value.get("error").is_none() {
return Err(format!("Plugin '{plugin_id}' response has neither result nor error"));
}
Ok(value.get("result").cloned().unwrap_or(serde_json::Value::Null))
}
async fn fail_pending(pending: &Mutex<HashMap<u64, PendingResponse>>, message: &str) {
let responses = std::mem::take(&mut *pending.lock().await);
for (_, sender) in responses {
let _ = sender.send(Err(message.to_string()));
}
}
fn validate_binary_channel(channel: &str) -> Result<(), String> {
if channel.is_empty() || channel.len() > u16::MAX as usize {
return Err("Plugin binary channel must contain 1-65535 bytes".to_string());
}
if channel.chars().any(char::is_whitespace) {
return Err("Plugin binary channel cannot contain whitespace".to_string());
}
Ok(())
}
fn validate_protocol_name(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.len() > 256
|| !value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '/' | '-'))
{
return Err("Plugin protocol name is invalid".to_string());
}
Ok(())
}
fn ensure_plugin_backend(plugin: &InstalledPlugin) -> Result<(), String> {
if !plugin.compatibility.compatible {
return Err(format!(
"Plugin '{}' is incompatible: {}",
plugin.manifest.id,
plugin.compatibility.errors.join("; ")
));
}
if plugin.compatibility.backend_executable.is_none() {
return Err(format!("Plugin '{}' does not provide a backend entrypoint", plugin.manifest.id));
}
Ok(())
}
fn spawn_plugin_child(plugin: &InstalledPlugin, app_version: &str, env: &PluginRuntimeEnv) -> Result<Child, String> {
let executable_path =
plugin.compatibility.backend_executable.as_ref().ok_or_else(|| {
format!("Plugin '{}' does not provide a compatible backend executable", plugin.manifest.id)
})?;
ensure_executable_permission(executable_path)?;
let mut command = crate::process::new_tokio_command(executable_path);
command
.current_dir(&plugin.path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env("DBX_PLUGIN_ID", &plugin.manifest.id)
.env("DBX_PLUGIN_VERSION", &plugin.manifest.version)
.env("DBX_APP_VERSION", app_version)
.env("DBX_HOST_API_VERSION", SUPPORTED_PLUGIN_HOST_API_VERSION)
.env("DBX_PLUGIN_PROTOCOL_VERSION", SUPPORTED_PLUGIN_PROTOCOL_VERSION.to_string());
env.apply_to(&mut command);
command.spawn().map_err(|error| format!("Failed to start plugin '{}': {error}", plugin.manifest.id))
}
fn ensure_executable_permission(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(path).map_err(|error| error.to_string())?.permissions().mode();
if mode & 0o111 == 0 {
return Err(format!("Plugin backend is not executable: {}", path.display()));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{
decode_response_value, read_limited_line, trim_ascii_whitespace, PluginSessionState, PluginSidecarSession,
};
use crate::plugins::{InstalledPlugin, PluginManifest, PluginRuntimeEnv};
use tokio::io::BufReader;
#[tokio::test]
async fn rejects_oversized_json_lines_before_unbounded_growth() {
let mut reader = BufReader::new(std::io::Cursor::new(b"123456\n"));
let error = read_limited_line(&mut reader, 4).await.expect_err("line should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
}
#[test]
fn decodes_json_rpc_result_and_error() {
assert_eq!(
decode_response_value("sample", serde_json::json!({ "id": 1, "result": { "ok": true } })).unwrap(),
serde_json::json!({ "ok": true })
);
assert_eq!(
decode_response_value("sample", serde_json::json!({ "id": 1, "error": { "message": "boom" } }))
.unwrap_err(),
"boom"
);
}
#[test]
fn trims_protocol_whitespace() {
assert_eq!(trim_ascii_whitespace(b" \n{}\r\n"), b"{}");
}
#[cfg(unix)]
#[tokio::test]
async fn negotiates_routes_concurrent_responses_and_forwards_events() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("plugin.sh");
std::fs::write(
&executable,
r#"#!/bin/sh
IFS= read -r initialize
initialize_id=$(printf '%s' "$initialize" | sed -E 's/.*"id":([0-9]+).*/\1/')
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"capabilities":["events"],"plugin":{"id":"sample.sidecar","version":"1.0.0"}}}\n' "$initialize_id"
IFS= read -r first
IFS= read -r second
first_id=$(printf '%s' "$first" | sed -E 's/.*"id":([0-9]+).*/\1/')
second_id=$(printf '%s' "$second" | sed -E 's/.*"id":([0-9]+).*/\1/')
first_method=$(printf '%s' "$first" | sed -E 's/.*"method":"([^"]+)".*/\1/')
second_method=$(printf '%s' "$second" | sed -E 's/.*"method":"([^"]+)".*/\1/')
printf '{"jsonrpc":"2.0","method":"sample/progress","params":{"value":50}}\n'
printf '{"jsonrpc":"2.0","id":%s,"result":"%s"}\n' "$second_id" "$second_method"
printf '{"jsonrpc":"2.0","id":%s,"result":"%s"}\n' "$first_id" "$first_method"
sleep 30
"#,
)
.unwrap();
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "sample.sidecar",
"name": "Sample Sidecar",
"version": "1.0.0",
"publisher": "dbx",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"permissions": ["host.events"],
"entrypoints": {
"backend": {
"protocol_versions": [1],
"transport": "stdio-jsonl",
"executable": "plugin.sh"
}
}
}))
.unwrap();
let plugin = InstalledPlugin::new(manifest, dir.path().to_path_buf(), "0.5.67");
let session = PluginSidecarSession::start(plugin, "0.5.67", PluginRuntimeEnv::default())
.await
.expect("v1 sidecar should initialize");
assert_eq!(session.status().state, PluginSessionState::Running);
assert_eq!(session.handshake().await.unwrap().capabilities, vec!["events"]);
let mut events = session.subscribe_events();
let first = session.invoke::<String>("sample/first", serde_json::Value::Null);
let second = session.invoke::<String>("sample/second", serde_json::Value::Null);
let (first, second) = tokio::join!(first, second);
assert_eq!(first.unwrap(), "sample/first");
assert_eq!(second.unwrap(), "sample/second");
let event = events.recv().await.unwrap();
assert_eq!(event.method, "sample/progress");
assert_eq!(event.params["value"], 50);
session.shutdown().await;
assert_eq!(session.status().state, PluginSessionState::Stopped);
}
#[cfg(unix)]
#[tokio::test]
async fn failed_initialize_terminates_plugin_process() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("plugin.sh");
let pid_file = dir.path().join("plugin.pid");
std::fs::write(
&executable,
format!(
r#"#!/bin/sh
printf '%s' "$$" > '{}'
IFS= read -r initialize
initialize_id=$(printf '%s' "$initialize" | sed -E 's/.*"id":([0-9]+).*/\1/')
printf '{{"jsonrpc":"2.0","id":%s,"result":{{"protocolVersion":1,"capabilities":[],"plugin":{{"id":"wrong.sidecar","version":"1.0.0"}}}}}}\n' "$initialize_id"
sleep 30
"#,
pid_file.display()
),
)
.unwrap();
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"manifest_version": 1,
"id": "sample.sidecar",
"name": "Sample Sidecar",
"version": "1.0.0",
"publisher": "dbx",
"engines": { "dbx": ">=0.1.0", "host_api": "^1.0" },
"entrypoints": {
"backend": {
"protocol_versions": [1],
"transport": "stdio-jsonl",
"executable": "plugin.sh"
}
}
}))
.unwrap();
let plugin = InstalledPlugin::new(manifest, dir.path().to_path_buf(), "0.5.67");
let error = match PluginSidecarSession::start(plugin, "0.5.67", PluginRuntimeEnv::default()).await {
Ok(session) => {
session.shutdown().await;
panic!("mismatched backend identity should fail initialization")
}
Err(error) => error,
};
assert!(error.contains("does not match manifest"));
let pid = std::fs::read_to_string(&pid_file).unwrap().parse::<u32>().unwrap();
for _ in 0..20 {
if !process_exists(pid) {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(!process_exists(pid), "plugin process {pid} survived failed initialization");
}
#[cfg(unix)]
#[tokio::test]
async fn legacy_jsonl_runtime_ignores_banner_output() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let executable = dir.path().join("plugin.sh");
std::fs::write(
&executable,
r#"#!/bin/sh
IFS= read -r request
request_id=$(printf '%s' "$request" | sed -E 's/.*"id":([0-9]+).*/\1/')
printf 'legacy banner\n'
printf '{"id":%s,"result":{"ok":true}}\n' "$request_id"
sleep 30
"#,
)
.unwrap();
let mut permissions = std::fs::metadata(&executable).unwrap().permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&executable, permissions).unwrap();
let manifest: PluginManifest = serde_json::from_value(serde_json::json!({
"id": "legacy",
"name": "Legacy",
"executable": "plugin.sh"
}))
.unwrap();
let plugin = InstalledPlugin::new(manifest, dir.path().to_path_buf(), "0.5.67");
let session = PluginSidecarSession::start(plugin, "0.5.67", PluginRuntimeEnv::default()).await.unwrap();
let result: serde_json::Value = session.invoke("ping", serde_json::Value::Null).await.unwrap();
assert_eq!(result["ok"], true);
session.shutdown().await;
}
#[cfg(unix)]
fn process_exists(pid: u32) -> bool {
std::process::Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
+4
View File
@@ -810,6 +810,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
one_time: false,
save_password: true,
read_only: false,
+39 -3
View File
@@ -2318,6 +2318,7 @@ async fn do_execute_typed(
.await
.map(|result| truncate_result_with_max_rows(result, max_rows))
}
PoolKind::PluginConnection(_) => Err("SQL execution is not supported for plugin connections".to_string()),
PoolKind::HBase(_) => Err("SQL execution is not supported for HBase connections".to_string()),
PoolKind::DynamoDb(client) => {
let client = client.clone();
@@ -4098,7 +4099,8 @@ fn pool_kind_has_transactional_path(pool: &PoolKind) -> bool {
| PoolKind::InfluxDb(_)
| PoolKind::InfluxDb3(_)
| PoolKind::VictoriaMetrics(_)
| PoolKind::ExternalDriver { .. } => false,
| PoolKind::ExternalDriver { .. }
| PoolKind::PluginConnection(_) => false,
#[cfg(feature = "mq-admin")]
PoolKind::Mqtt(_) => false,
}
@@ -4468,7 +4470,8 @@ fn batch_transaction_path(pool: &PoolKind) -> BatchTransactionPath {
| PoolKind::InfluxDb(_)
| PoolKind::InfluxDb3(_)
| PoolKind::VictoriaMetrics(_)
| PoolKind::ExternalDriver { .. } => BatchTransactionPath::Unsupported,
| PoolKind::ExternalDriver { .. }
| PoolKind::PluginConnection(_) => BatchTransactionPath::Unsupported,
}
}
@@ -6553,7 +6556,8 @@ mod tests {
use crate::models::connection::{default_redis_key_separator, ConnectionConfig, DatabaseType};
#[cfg(unix)]
use crate::plugins::{
InstalledPlugin, PluginDriverManifest, PluginDriverSession, PluginManifest, PluginRuntimeEnv,
InstalledPlugin, PluginCompatibility, PluginDriverManifest, PluginDriverSession, PluginManifest,
PluginRuntimeEnv,
};
use crate::storage::Storage;
@@ -6880,6 +6884,10 @@ for line in sys.stdin:
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -8671,8 +8679,14 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility {
compatible: true,
backend_executable: Some(dir.join("plugin.sh")),
..Default::default()
},
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -8771,8 +8785,14 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility {
compatible: true,
backend_executable: Some(dir.join("plugin.sh")),
..Default::default()
},
};
let session = Arc::new(
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
@@ -8881,8 +8901,14 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility {
compatible: true,
backend_executable: Some(dir.join("plugin.sh")),
..Default::default()
},
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -8939,8 +8965,14 @@ for line in sys.stdin:
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..Default::default()
},
path: dir.clone(),
compatibility: PluginCompatibility {
compatible: true,
backend_executable: Some(dir.join("plugin.sh")),
..Default::default()
},
};
let session = PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default())
.await
@@ -9400,6 +9432,10 @@ for line in sys.stdin:
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: Default::default(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
+10
View File
@@ -3409,6 +3409,10 @@ mod tests {
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
plugin_id: None,
plugin_connection_provider: None,
plugin_connection_type: None,
connection_secrets: HashMap::new(),
jdbc_driver_class: None,
jdbc_driver_paths: Vec::new(),
one_time: false,
@@ -3636,8 +3640,14 @@ done
kind: "external".to_string(),
database_type: Some("jdbc".to_string()),
}],
..PluginManifest::default()
},
path: dir.clone(),
compatibility: crate::plugins::PluginCompatibility {
compatible: true,
backend_executable: Some(dir.join("plugin.sh")),
..Default::default()
},
};
let session = std::sync::Arc::new(
PluginDriverSession::start_for_test(plugin, "jdbc".to_string(), PluginRuntimeEnv::default()).await.unwrap(),

Some files were not shown because too many files have changed in this diff Show More