diff --git a/.github/workflows/plugin-cli.yml b/.github/workflows/plugin-cli.yml new file mode 100644 index 00000000..d863b249 --- /dev/null +++ b/.github/workflows/plugin-cli.yml @@ -0,0 +1,92 @@ +name: Build plugin CLI +on: + workflow_dispatch: + inputs: + publish: + description: Publish assembled CLI to npm + type: boolean + default: false +permissions: + contents: read +jobs: + native: + strategy: + fail-fast: false + matrix: + include: + - { + runner: windows-latest, + platform: windows-x86_64, + extension: .exe, + } + - { + runner: windows-11-arm, + platform: windows-aarch64, + extension: .exe, + } + - { runner: ubuntu-22.04, platform: linux-x86_64, extension: "" } + - { runner: ubuntu-24.04-arm, platform: linux-aarch64, extension: "" } + - { runner: macos-15-intel, platform: macos-x86_64, extension: "" } + - { runner: macos-14, platform: macos-aarch64, extension: "" } + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.94.0 + - run: cargo build --locked --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin + - name: Name platform binary + shell: bash + env: + PLATFORM: ${{ matrix.platform }} + EXT: ${{ matrix.extension }} + run: | + mkdir -p plugins/cli/native + cp "src-tauri/crates/nyaterm-plugin-runtime/target/release/nyaterm-plugin$EXT" "plugins/cli/native/nyaterm-plugin-$PLATFORM$EXT" + - uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.platform }} + path: plugins/cli/native/* + assemble: + needs: native + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - uses: actions/download-artifact@v4 + with: + pattern: cli-* + path: plugins/cli/native + merge-multiple: true + - run: npm pack ./plugins/cli + env: + NYATERM_CLI_RELEASE: "1" + - name: Verify standalone package + run: | + npm install -g ./nyaterm-plugin-cli-*.tgz + nyaterm-plugin create example.smoke work/plugin + nyaterm-plugin pack work/plugin work/smoke.nyap + nyaterm-plugin inspect work/smoke.nyap + - uses: actions/upload-artifact@v4 + with: + name: npm-plugin-cli + path: nyaterm-plugin-cli-*.tgz + publish: + if: inputs.publish && github.ref == 'refs/heads/main' + needs: assemble + runs-on: ubuntu-latest + environment: plugin-cli-publishing + permissions: + id-token: write + contents: read + steps: + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - uses: actions/download-artifact@v4 + with: + name: npm-plugin-cli + - run: npm publish ./nyaterm-plugin-cli-*.tgz --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/docs/plugins.md b/docs/plugins.md index 1f6fd51d..1389e43c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -52,7 +52,37 @@ verify consistency and the reviewed package digest prevents a file swap between inspection and installation; these are not publisher authentication. Publisher names are self-declared. A changed publisher label cannot overwrite an existing ID without uninstalling it first, but that continuity check is not a signature. -The local v1 format does not include cryptographic publisher signing. +Local packages may remain unsigned. The official Plugin Store uses repository +Ed25519 signing: `signature.json` signs the exact bytes of `checksums.json`, which +covers every content file (including `manifest.json`) but excludes `checksums.json` +and `signature.json` themselves. Publisher verification and package signatures are +separate concepts; a signature authenticates the Store's reviewed artifact. + +The Marketplace downloads and verifies packages in Rust, checks the catalog's final +SHA-256 and size, and cross-checks ID, version, publisher, permissions and signing +key. Public keys are pinned in the client; catalog metadata cannot add trusted keys. +The review dialog shows the verified package before installation, and installation +leaves the plugin disabled until its runtime permissions are separately granted. +Installed versions persist provenance; switching between local and Store sources +requires uninstalling first. Old registry records default to local provenance. + +The Store lives in [`nyakang/nyaterm-plugins`](https://github.com/nyakang/nyaterm-plugins). +Its records and signing workflow are independent of author plugin source. First +rollout requires initializing the official key and configuring the protected +`plugin-signing` environment; an empty client trust store rejects every Store install. + +The standalone [`@nyaterm/plugin-cli`](../plugins/cli/README.md) package bundles the +authoring binary, SDK and templates, with a workflow to assemble six platforms: + +```sh +npm install -g @nyaterm/plugin-cli +nyaterm-plugin create example.hello ./hello +nyaterm-plugin pack ./hello ./hello-1.0.0.nyap +nyaterm-plugin inspect ./hello-1.0.0.nyap +``` + +Publication to npm is a separate release step. Distributed Rust templates use a +pinned public Git SDK dependency; source-checkout templates retain local paths. Plugin files live in the active runtime's `plugins/` directory (normally `~/.nyaterm/plugins/`): diff --git a/plugins/README.md b/plugins/README.md index f0e46e7d..7bfb6206 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -6,6 +6,8 @@ permissions, the UI SDK and native protocol. - `sdk/nyaterm.js` and `sdk/nyaterm.d.ts`: browser SDK and typings. - `examples/session-toolbox`: a sandboxed panel using scoped host capabilities. - `examples/native-counter`: a persistent Rust command backend. +- `cli/`: independently publishable `@nyaterm/plugin-cli`, bundling the authoring + binary, browser SDK and templates. See its README for standalone usage and release builds. ```sh pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap diff --git a/plugins/cli/.gitignore b/plugins/cli/.gitignore new file mode 100644 index 00000000..bc288221 --- /dev/null +++ b/plugins/cli/.gitignore @@ -0,0 +1,3 @@ +assets/ +native/ +*.tgz diff --git a/plugins/cli/README.md b/plugins/cli/README.md new file mode 100644 index 00000000..d86efd94 --- /dev/null +++ b/plugins/cli/README.md @@ -0,0 +1,22 @@ +# @nyaterm/plugin-cli + +Standalone plugin authoring tools. The npm package bundles platform binaries, browser SDK and templates; it does not clone NyaTerm or compile Rust to pack/inspect UI plugins. Node 22+ is required. Native plugin development additionally requires Rust 1.94+. + +```sh +npm install -g @nyaterm/plugin-cli +nyaterm-plugin create example.hello ./hello +nyaterm-plugin pack ./hello ./hello-1.0.0.nyap +nyaterm-plugin inspect ./hello-1.0.0.nyap +nyaterm-plugin create example.native ./native --template rust +``` + +Pack/inspect use the same Rust validator as the desktop installer. Packages are unsigned author candidates. Submit them through the Store review/signing process; never use an official signing key in an author repository. Rust templates consume the SDK through a pinned public Git commit. + +For local packaging from NyaTerm source: + +```sh +cargo build --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin +npm pack ./plugins/cli +``` + +A local tarball contains the current platform binary. The **Build plugin CLI** workflow assembles all six supported binaries, checks the release completeness and produces a publishable npm tarball. Its optional publication job requires the `plugin-cli-publishing` environment and `NPM_TOKEN`; build-only runs do not publish. Use a reviewed platform commit and verify npm package name ownership before first publication. diff --git a/plugins/cli/bin/nyaterm-plugin.mjs b/plugins/cli/bin/nyaterm-plugin.mjs new file mode 100644 index 00000000..2bfd7ee5 --- /dev/null +++ b/plugins/cli/bin/nyaterm-plugin.mjs @@ -0,0 +1,47 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const [command, ...args] = process.argv.slice(2); +const os = { win32: "windows", darwin: "macos", linux: "linux" }[ + process.platform +]; +const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch]; +const binary = path.join( + root, + "native", + `nyaterm-plugin-${os}-${arch}${process.platform === "win32" ? ".exe" : ""}`, +); +const appVersion = JSON.parse( + readFileSync(path.join(root, "assets/package.json"), "utf8"), +).version; +let executable, parameters; +if (command === "create") { + executable = process.execPath; + parameters = [path.join(root, "assets/scripts/create-plugin.mjs"), ...args]; +} else if ( + (command === "pack" && [2, 3].includes(args.length)) || + (command === "inspect" && [1, 2].includes(args.length)) +) { + if (!os || !arch || !existsSync(binary)) + throw new Error( + "This CLI package has no binary for your platform. Supported: Windows, Linux and macOS x86_64/aarch64.", + ); + executable = binary; + parameters = [command, ...args]; + if (args.length === (command === "pack" ? 2 : 1)) parameters.push(appVersion); +} else { + process.stderr.write( + "Usage: nyaterm-plugin create [--template ui|rust]\n nyaterm-plugin pack [app-version]\n nyaterm-plugin inspect [app-version]\n", + ); + process.exit(1); +} +const result = spawnSync(executable, parameters, { + stdio: "inherit", + windowsHide: true, +}); +if (result.error) process.stderr.write(`${result.error.message}\n`); +process.exit(result.status ?? 1); diff --git a/plugins/cli/package.json b/plugins/cli/package.json new file mode 100644 index 00000000..b3375281 --- /dev/null +++ b/plugins/cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@nyaterm/plugin-cli", + "version": "0.1.0", + "description": "Standalone NyaTerm plugin authoring CLI and templates", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/nyakang/nyaterm.git", + "directory": "plugins/cli" + }, + "engines": { + "node": ">=22" + }, + "bin": { + "nyaterm-plugin": "bin/nyaterm-plugin.mjs" + }, + "files": [ + "bin", + "assets", + "native", + "README.md" + ], + "scripts": { + "prepack": "node scripts/prepare.mjs" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/plugins/cli/scripts/prepare.mjs b/plugins/cli/scripts/prepare.mjs new file mode 100644 index 00000000..ad7fdd3e --- /dev/null +++ b/plugins/cli/scripts/prepare.mjs @@ -0,0 +1,73 @@ +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const cli = path.join(root, "plugins/cli"); +const assets = path.join(cli, "assets"); +mkdirSync(path.join(assets, "scripts"), { recursive: true }); +mkdirSync(path.join(assets, "plugins/sdk"), { recursive: true }); +copyFileSync( + path.join(root, "scripts/create-plugin.mjs"), + path.join(assets, "scripts/create-plugin.mjs"), +); +for (const file of ["nyaterm.js", "nyaterm.d.ts"]) + copyFileSync( + path.join(root, "plugins/sdk", file), + path.join(assets, "plugins/sdk", file), + ); +cpSync( + path.join(root, "plugins/templates"), + path.join(assets, "plugins/templates"), + { recursive: true }, +); +writeFileSync( + path.join(assets, "package.json"), + `${JSON.stringify({ type: "module", version: JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")).version }, null, 2)}\n`, +); +chmodSync(path.join(cli, "bin/nyaterm-plugin.mjs"), 0o755); +const os = { win32: "windows", darwin: "macos", linux: "linux" }[ + process.platform +]; +const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch]; +const native = path.join(cli, "native"); +mkdirSync(native, { recursive: true }); +const name = `nyaterm-plugin-${os}-${arch}${process.platform === "win32" ? ".exe" : ""}`; +const compiled = + process.env.NYATERM_PLUGIN_BINARY ?? + path.join( + root, + "src-tauri/crates/nyaterm-plugin-runtime/target/release", + `nyaterm-plugin${process.platform === "win32" ? ".exe" : ""}`, + ); +if (existsSync(compiled)) { + copyFileSync(compiled, path.join(native, name)); +} else if (!existsSync(path.join(native, name))) { + throw new Error( + "Build the native CLI first: cargo build --release --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --bin nyaterm-plugin", + ); +} +for (const platform of [ + "windows-x86_64", + "windows-aarch64", + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", +]) { + const file = path.join( + native, + `nyaterm-plugin-${platform}${platform.startsWith("windows") ? ".exe" : ""}`, + ); + if (process.env.NYATERM_CLI_RELEASE === "1" && !existsSync(file)) + throw new Error(`Release is missing native binary: ${platform}`); + if (existsSync(file)) chmodSync(file, 0o755); +} diff --git a/scripts/create-plugin.mjs b/scripts/create-plugin.mjs index 99b4417b..e5d0d464 100644 --- a/scripts/create-plugin.mjs +++ b/scripts/create-plugin.mjs @@ -40,10 +40,12 @@ if (existsSync(target)) const { version } = JSON.parse( readFileSync(path.join(root, "package.json"), "utf8"), ); -const sdkPath = path - .relative(target, path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk")) - .split(path.sep) - .join("/"); +const distributed = !existsSync( + path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml"), +); +const sdkDependency = distributed + ? '{ git = "https://github.com/nyakang/nyaterm", rev = "ac014d96672b5c33736bdc364be9c21b44ec95dc" }' + : `{ path = ${JSON.stringify(path.relative(target, path.join(root, "plugins/sdk/rust/nyaterm-plugin-sdk")).split(path.sep).join("/"))} }`; const manifest = { manifestVersion: 1, id, @@ -105,7 +107,7 @@ try { if (template === "rust") { put( "Cargo.toml", - `[package]\nname = "nyaterm-plugin-starter"\nversion = "1.0.0"\nedition = "2024"\nrust-version = "1.94"\n\n[dependencies]\nnyaterm-plugin-sdk = { path = ${JSON.stringify(sdkPath)} }\ntokio = { version = "1", features = ["rt", "macros"] }\n`, + `[package]\nname = "nyaterm-plugin-starter"\nversion = "1.0.0"\nedition = "2024"\nrust-version = "1.94"\n\n[dependencies]\nnyaterm-plugin-sdk = ${sdkDependency}\ntokio = { version = "1", features = ["rt", "macros"] }\n`, ); put( "src/main.rs", @@ -117,9 +119,12 @@ try { ); } const packer = path.join(root, "scripts/package-plugin.mjs"); + const packCommand = distributed + ? `nyaterm-plugin pack . ../${id}.nyap` + : `node ${JSON.stringify(packer)} . ../${id}.nyap`; put( "README.md", - `# ${id}\n\nGenerated from the NyaTerm ${template} template.\n\n${template === "rust" ? "1. Install Rust 1.94 or newer, then run `node build.mjs` here. This builds the SDK backend and fills the current platform executable in manifest.json.\n2." : "1."} Package from this directory:\n\n\`\`\`sh\nnode ${JSON.stringify(packer)} . ../${id}.nyap\n\`\`\`\n\nInstall the .nyap from NyaTerm's Plugins panel, review permissions, enable it and open its panel. ${template === "rust" ? "The native command reuses the process; check status/logs in Plugins. Stop the backend to reset its counter. Never print protocol data to stdout: use Context::log or stderr. The Rust SDK is a relative local dependency; distribute this source with the SDK or change its path when moving the project. For framed transport change backend.transport to stdio-framed; the SDK selects the transport from the host environment. Add other-platform binaries under bin/ and their executable entries before packing." : "Select a connected terminal before inspecting its session. The UI runs in an isolated iframe; use the SDK instead of Tauri APIs or direct file/network access."}\n\nEdit manifest.json to add permissions and contributions. UI scripts and styles must be local; use textContent for host/plugin data. Packaging includes only manifest.json, ui/, assets/ and bin/; target and source are excluded. The packager injects the current UI SDK. To update an installed plugin, bump manifest.version (and Cargo version for Rust) because released versions are immutable.\n`, + `# ${id}\n\nGenerated from the NyaTerm ${template} template.\n\n${template === "rust" ? "1. Install Rust 1.94 or newer, then run `node build.mjs` here. This builds the SDK backend and fills the current platform executable in manifest.json.\n2." : "1."} Package from this directory:\n\n\`\`\`sh\n${packCommand}\n\`\`\`\n\nInstall the .nyap from NyaTerm's Plugins panel, review permissions, enable it and open its panel. ${template === "rust" ? "The native command reuses the process; check status/logs in Plugins. Stop the backend to reset its counter. Never print protocol data to stdout: use Context::log or stderr. The standalone CLI pins the Rust SDK to a public Git commit; source-checkout templates use a local SDK dependency. For framed transport change backend.transport to stdio-framed; the SDK selects the transport from the host environment. Add other-platform binaries under bin/ and their executable entries before packing." : "Select a connected terminal before inspecting its session. The UI runs in an isolated iframe; use the SDK instead of Tauri APIs or direct file/network access."}\n\nEdit manifest.json to add permissions and contributions. UI scripts and styles must be local; use textContent for host/plugin data. Packaging includes only manifest.json, ui/, assets/ and bin/; target and source are excluded. The packager injects the current UI SDK. To update an installed plugin, bump manifest.version (and Cargo version for Rust) because released versions are immutable.\n`, ); renameSync(stage, target); } catch (error) { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 35218666..c0f96596 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1428,6 +1428,7 @@ dependencies = [ "fiat-crypto 0.2.9", "rustc_version", "subtle", + "zeroize", ] [[package]] @@ -1969,6 +1970,7 @@ 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", ] @@ -1990,8 +1992,10 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek 4.1.3", "ed25519 2.2.3", + "serde", "sha2 0.10.9", "subtle", + "zeroize", ] [[package]] @@ -4975,6 +4979,7 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "thiserror 2.0.20", "time", "tokio", @@ -5022,6 +5027,8 @@ name = "nyaterm-plugin-runtime" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", + "ed25519-dalek 2.2.0", "hex", "nyaterm-gpu", "regex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9cb63374..1fd9cb77 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,7 @@ tauri-build = { version = "2", features = [] } tokio = { version = "1", features = ["test-util"] } [dependencies] +tempfile = "3" nyaterm-gpu = { path = "crates/nyaterm-gpu" } nyaterm-otp = { path = "crates/otp" } nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" } diff --git a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock index b8c36087..21183ccc 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock +++ b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.lock @@ -19,9 +19,21 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.2" @@ -55,6 +67,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -83,6 +101,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -101,7 +156,31 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", ] [[package]] @@ -126,6 +205,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "flate2" version = "1.1.10" @@ -158,7 +243,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -196,6 +281,17 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -403,6 +499,8 @@ name = "nyaterm-plugin-runtime" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "ed25519-dalek", "hex", "nyaterm-gpu", "nyaterm-plugin-sdk", @@ -451,6 +549,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -484,6 +592,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "regex" version = "1.13.1" @@ -513,6 +630,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.5" @@ -565,7 +691,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -602,6 +728,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", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -620,12 +755,39 @@ version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -645,7 +807,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -655,7 +817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -678,7 +840,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -714,7 +876,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -773,7 +935,7 @@ version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -822,7 +984,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -875,7 +1037,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] @@ -896,10 +1058,16 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -930,7 +1098,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] diff --git a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml index 5ec24cc6..a33986b1 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml +++ b/src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" rust-version = "1.94" [dependencies] +base64 = "0.22" +ed25519-dalek = "2" nyaterm-gpu = { path = "../nyaterm-gpu" } async-trait = "0.1" hex = "0.4" diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs index 7c2c2ed9..c3b954c3 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/bin/nyaterm-plugin.rs @@ -29,7 +29,7 @@ fn run() -> Result<(), Box> { let sdk = include_bytes!("../../../../../plugins/sdk/nyaterm.js").to_vec(); if files.get("ui/nyaterm-sdk.js").is_some_and(|bytes| bytes != &sdk) { return Err("ui/nyaterm-sdk.js is reserved for the host SDK".into()); } files.insert("ui/nyaterm-sdk.js".into(), sdk); - if files.len() > 1023 || files.values().map(Vec::len).sum::() > 256 * 1024 * 1024 { return Err("Plugin package exceeds file count or expanded size limit".into()); } + if files.len() > 1022 || files.values().map(Vec::len).sum::() > 256 * 1024 * 1024 { return Err("Plugin package exceeds file count or expanded size limit".into()); } let parent = output.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or_else(|| Path::new(".")); std::fs::create_dir_all(parent)?; let mut staging = tempfile::NamedTempFile::new_in(parent)?; @@ -83,7 +83,7 @@ fn collect( .ok_or("Non-UTF-8 plugin path")? .replace('\\', "/"); validate_path(&relative)?; - if files.len() >= 1023 || metadata.len() > 64 * 1024 * 1024 { + if files.len() >= 1022 || metadata.len() > 64 * 1024 * 1024 { return Err("Plugin source is too large".into()); } if files.values().map(Vec::len).sum::() + metadata.len() as usize > 256 * 1024 * 1024 diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs index a9359018..8e98c9b1 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/lib.rs @@ -1,11 +1,13 @@ //! Tauri-independent installation, manifest validation and sidecar transport. pub mod diagnostics; pub mod manifest; +pub mod marketplace; pub mod monitoring; pub mod package; pub mod probe; pub mod registry; pub mod sidecar; +pub mod trust; #[derive(Debug, thiserror::Error)] pub enum Error { diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs new file mode 100644 index 00000000..a46406eb --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/marketplace.rs @@ -0,0 +1,525 @@ +//! Catalog protocol and cross-validation; network I/O belongs to the host adapter. +use crate::manifest::{valid_id, validate_permission}; +use crate::package::{MAX_PACKAGE_BYTES, PreparedPackage}; +use crate::registry::Provenance; +use crate::trust::{SignatureStatus, inspect}; +use crate::{Result, invalid}; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +pub const REPOSITORY_ID: &str = "nyaterm-official"; +pub const CATALOG_URL: &str = + "https://raw.githubusercontent.com/nyakang/nyaterm-plugins/main/catalog/index.json"; +pub const MAX_CATALOG_BYTES: usize = 4 * 1024 * 1024; +pub const TARGETS: &[&str] = &[ + "universal", + "windows-x86_64", + "windows-aarch64", + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Repository { + pub id: String, + pub name: String, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Catalog { + pub catalog_version: u32, + pub repository: Repository, + pub generated_at: String, + pub plugins: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CatalogPlugin { + pub id: String, + pub name: String, + pub description: String, + pub publisher: String, + pub verified: bool, + pub tags: Vec, + pub source: String, + pub homepage: String, + pub license: String, + pub permissions: Vec, + pub latest_version: String, + pub versions: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CatalogVersion { + pub version: String, + pub released_at: String, + pub release_notes: String, + // Version-scoped permissions allow safe permission changes on future upgrades. + pub permissions: Vec, + pub artifacts: Vec, +} +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Artifact { + pub target: String, + pub url: String, + pub sha256: String, + pub size: u64, + pub signing_key_id: String, +} + +pub fn https_url(value: &str) -> Result { + let url = url::Url::parse(value).map_err(|_| invalid("Invalid Store URL"))?; + if value.len() > 4096 + || url.scheme() != "https" + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(invalid( + "Store URLs must use HTTPS without credentials or fragments", + )); + } + Ok(url) +} + +fn permissions(values: &[String]) -> Result<()> { + if values.len() > 24 || values.iter().collect::>().len() != values.len() { + return Err(invalid("Invalid catalog permissions")); + } + for value in values { + validate_permission(value)?; + } + Ok(()) +} + +impl Catalog { + pub fn parse(bytes: &[u8]) -> Result { + if bytes.len() > MAX_CATALOG_BYTES { + return Err(invalid("Store catalog is too large")); + } + let catalog: Self = serde_json::from_slice(bytes)?; + if catalog.catalog_version != 1 + || catalog.repository.id != REPOSITORY_ID + || catalog.plugins.len() > 2048 + { + return Err(invalid("Unsupported Store catalog")); + } + let mut ids = HashSet::new(); + for plugin in &catalog.plugins { + if !valid_id(&plugin.id) + || !plugin.id.contains('.') + || !ids.insert(&plugin.id) + || plugin.name.trim().is_empty() + || plugin.name.len() > 120 + || plugin.description.len() > 2000 + || plugin.publisher.trim().is_empty() + || plugin.publisher.len() > 120 + || plugin.versions.is_empty() + || plugin.versions.len() > 128 + { + return Err(invalid("Invalid Store plugin metadata")); + } + https_url(&plugin.source)?; + https_url(&plugin.homepage)?; + permissions(&plugin.permissions)?; + let mut versions = HashSet::new(); + for version in &plugin.versions { + let parsed = semver::Version::parse(&version.version) + .map_err(|_| invalid("Invalid Store version"))?; + if parsed.to_string() != version.version + || version.version.len() > 80 + || !versions.insert(&version.version) + || version.artifacts.is_empty() + || version.artifacts.len() > TARGETS.len() + { + return Err(invalid("Invalid or duplicate Store version")); + } + permissions(&version.permissions)?; + let mut targets = HashSet::new(); + for artifact in &version.artifacts { + https_url(&artifact.url)?; + if !TARGETS.contains(&artifact.target.as_str()) + || !targets.insert(&artifact.target) + || artifact.size == 0 + || artifact.size > MAX_PACKAGE_BYTES + || artifact.sha256.len() != 64 + || !artifact + .sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || !valid_id(&artifact.signing_key_id) + { + return Err(invalid("Invalid Store artifact")); + } + } + } + let latest = plugin + .versions + .iter() + .find(|v| v.version == plugin.latest_version) + .ok_or_else(|| invalid("Store latest version does not exist"))?; + if latest.permissions != plugin.permissions { + return Err(invalid("Latest Store permissions disagree")); + } + } + Ok(catalog) + } + + pub fn select( + &self, + id: &str, + version: &str, + target: &str, + ) -> Result<(&CatalogPlugin, &CatalogVersion, &Artifact)> { + let plugin = self + .plugins + .iter() + .find(|p| p.id == id) + .ok_or_else(|| invalid("Plugin is not in the Store"))?; + let version = plugin + .versions + .iter() + .find(|v| v.version == version) + .ok_or_else(|| invalid("Version is not in the Store"))?; + let artifact = version + .artifacts + .iter() + .find(|a| a.target == target) + .or_else(|| version.artifacts.iter().find(|a| a.target == "universal")) + .ok_or_else(|| invalid("Plugin does not support this platform"))?; + Ok((plugin, version, artifact)) + } +} + +pub fn current_target() -> String { + format!( + "{}-{}", + if cfg!(target_os = "macos") { + "macos" + } else { + std::env::consts::OS + }, + std::env::consts::ARCH + ) +} + +pub fn validate_package( + package: &mut PreparedPackage, + plugin: &CatalogPlugin, + version: &CatalogVersion, + artifact: &Artifact, + size: u64, + keys: &[(&str, &str)], +) -> Result { + if size != artifact.size || package.preview.digest != artifact.sha256 { + return Err(invalid( + "Downloaded Store package size or SHA-256 differs from catalog", + )); + } + let status = inspect(package.directory.path(), keys)?; + if status + != (SignatureStatus::Verified { + key_id: artifact.signing_key_id.clone(), + }) + { + return Err(invalid( + "Store package requires the catalog's trusted signature", + )); + } + let manifest = &package.preview.manifest; + let mut requested = manifest.permissions.clone(); + requested.sort(); + let mut advertised = version.permissions.clone(); + advertised.sort(); + if manifest.id != plugin.id + || manifest.version != version.version + || manifest.publisher != plugin.publisher + || requested != advertised + { + return Err(invalid( + "Store package identity or permissions differ from catalog", + )); + } + if let Some(backend) = &manifest.backend { + let target = if artifact.target == "universal" { + current_target() + } else { + artifact.target.clone() + }; + if !backend.executables.contains_key(&target) { + return Err(invalid("Store package has no backend for its target")); + } + } + package.preview.signature = status; + let provenance = Provenance::Marketplace { + repository_id: REPOSITORY_ID.into(), + publisher: plugin.publisher.clone(), + signing_key_id: artifact.signing_key_id.clone(), + package_sha256: artifact.sha256.clone(), + }; + package.provenance = provenance.clone(); + Ok(provenance) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine, engine::general_purpose::STANDARD}; + use ed25519_dalek::{Signer, SigningKey}; + use sha2::{Digest, Sha256}; + use std::collections::BTreeMap; + + fn fixture(signed: bool) -> (tempfile::TempDir, PreparedPackage, CatalogPlugin, String) { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("package.nyap"); + let manifest = br#"{"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"test","publisher":"example","engine":">=1.0.0","permissions":["session.read"]}"#; + let checksums = BTreeMap::from([ + ("manifest.json", hex::encode(Sha256::digest(manifest))), + ( + "ui/index.html", + hex::encode(Sha256::digest(b"

Hello

")), + ), + ]); + let key = SigningKey::from_bytes(&[42; 32]); + let document = serde_json::to_vec(&crate::trust::PackageSignature { + algorithm: "ed25519".into(), + key_id: "test".into(), + signature: STANDARD.encode( + key.sign(&serde_json::to_vec(&checksums).unwrap()) + .to_bytes(), + ), + }) + .unwrap(); + let extra = [("signature.json", document.as_slice())]; + crate::package::tests::package_manifest(&path, manifest, if signed { &extra } else { &[] }); + let package = crate::package::prepare(&path, temp.path(), "1.2.12").unwrap(); + let plugin = CatalogPlugin { + id: "example.tools".into(), + name: "Tools".into(), + description: "test".into(), + publisher: "example".into(), + verified: false, + tags: vec![], + source: "https://github.com/example/tools".into(), + homepage: "https://github.com/example/tools".into(), + license: "MIT".into(), + permissions: vec!["session.read".into()], + latest_version: "1.0.0".into(), + versions: vec![CatalogVersion { + version: "1.0.0".into(), + released_at: "2026-10-03T00:00:00Z".into(), + release_notes: "test".into(), + permissions: vec!["session.read".into()], + artifacts: vec![Artifact { + target: "universal".into(), + url: "https://example.com/package.nyap".into(), + sha256: package.preview.digest.clone(), + size: std::fs::metadata(path).unwrap().len(), + signing_key_id: "test".into(), + }], + }], + }; + ( + temp, + package, + plugin, + STANDARD.encode(key.verifying_key().to_bytes()), + ) + } + + #[test] + fn signed_store_install_is_disabled_and_provenance_survives_restart() { + let (temp, mut package, plugin, public) = fixture(true); + let version = &plugin.versions[0]; + let artifact = &version.artifacts[0]; + validate_package( + &mut package, + &plugin, + version, + artifact, + artifact.size, + &[("test", &public)], + ) + .unwrap(); + let root = temp.path().join("installed"); + let mut registry = crate::registry::Registry::open(root.clone(), "1.2.12".into()).unwrap(); + let installed = registry.install(package, &artifact.sha256).unwrap(); + assert!(!installed.enabled); + assert!(matches!( + installed.active().unwrap().provenance, + Provenance::Marketplace { .. } + )); + let mut registry = crate::registry::Registry::open(root, "1.2.12".into()).unwrap(); + assert!(matches!( + registry + .get("example.tools") + .unwrap() + .active() + .unwrap() + .provenance, + Provenance::Marketplace { .. } + )); + let (_local_temp, local, _, _) = fixture(false); + let digest = local.preview.digest.clone(); + assert!(registry.install(local, &digest).is_err()); + // The opposite source switch also requires an explicit uninstall. + let (temp, local, _, _) = fixture(false); + let root = temp.path().join("local"); + let mut registry = crate::registry::Registry::open(root, "1.2.12".into()).unwrap(); + let digest = local.preview.digest.clone(); + registry.install(local, &digest).unwrap(); + let (_temp, mut package, plugin, public) = fixture(true); + let version = &plugin.versions[0]; + let artifact = &version.artifacts[0]; + validate_package( + &mut package, + &plugin, + version, + artifact, + artifact.size, + &[("test", &public)], + ) + .unwrap(); + assert!(registry.install(package, &artifact.sha256).is_err()); + } + + #[test] + fn store_requires_signature_and_cross_validates_all_security_metadata() { + let (_temp, mut package, plugin, public) = fixture(true); + let version = &plugin.versions[0]; + let artifact = &version.artifacts[0]; + let keys = [("test", public.as_str())]; + assert!( + validate_package(&mut package, &plugin, version, artifact, artifact.size, &[]).is_err() + ); + assert!( + validate_package( + &mut package, + &plugin, + version, + artifact, + artifact.size - 1, + &keys + ) + .is_err() + ); + let mut wrong = artifact.clone(); + wrong.sha256 = "0".repeat(64); + assert!( + validate_package(&mut package, &plugin, version, &wrong, artifact.size, &keys).is_err() + ); + wrong = artifact.clone(); + wrong.signing_key_id = "other".into(); + assert!( + validate_package(&mut package, &plugin, version, &wrong, artifact.size, &keys).is_err() + ); + for (id, publisher) in [("example.other", "example"), ("example.tools", "attacker")] { + let mut wrong = plugin.clone(); + wrong.id = id.into(); + wrong.publisher = publisher.into(); + assert!( + validate_package( + &mut package, + &wrong, + version, + artifact, + artifact.size, + &keys + ) + .is_err() + ); + } + let mut wrong = version.clone(); + wrong.permissions.push("native".into()); + assert!( + validate_package( + &mut package, + &plugin, + &wrong, + artifact, + artifact.size, + &keys + ) + .is_err() + ); + wrong = version.clone(); + wrong.version = "2.0.0".into(); + assert!( + validate_package( + &mut package, + &plugin, + &wrong, + artifact, + artifact.size, + &keys + ) + .is_err() + ); + let (_temp, mut unsigned, mut metadata, _) = fixture(false); + metadata.versions[0].artifacts[0].sha256 = unsigned.preview.digest.clone(); + let version = &metadata.versions[0]; + let artifact = &version.artifacts[0]; + assert!( + validate_package( + &mut unsigned, + &metadata, + version, + artifact, + artifact.size, + &keys + ) + .is_err() + ); + } + + #[test] + fn selects_exact_target_before_universal_and_rejects_bad_catalogs() { + let (_temp, _, mut plugin, _) = fixture(true); + let mut exact = plugin.versions[0].artifacts[0].clone(); + exact.target = "windows-x86_64".into(); + plugin.versions[0].artifacts.push(exact); + let mut catalog = Catalog { + catalog_version: 1, + repository: Repository { + id: REPOSITORY_ID.into(), + name: "Store".into(), + }, + generated_at: "2026-10-03T00:00:00Z".into(), + plugins: vec![plugin], + }; + Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).unwrap(); + assert_eq!( + catalog + .select("example.tools", "1.0.0", "windows-x86_64") + .unwrap() + .2 + .target, + "windows-x86_64" + ); + assert_eq!( + catalog + .select("example.tools", "1.0.0", "linux-aarch64") + .unwrap() + .2 + .target, + "universal" + ); + assert!( + catalog + .select("example.tools", "2.0.0", "linux-aarch64") + .is_err() + ); + catalog.plugins[0].versions[0].artifacts[0].url = "http://example.com/package".into(); + assert!(Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).is_err()); + catalog.plugins[0].versions[0].artifacts[0].url = "https://example.com/package".into(); + catalog.plugins[0].versions[0] + .permissions + .push("native".into()); + assert!(Catalog::parse(&serde_json::to_vec(&catalog).unwrap()).is_err()); + } +} diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs index 3dd93de8..5e0d26a1 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/package.rs @@ -19,11 +19,13 @@ pub struct PackagePreview { pub digest: String, pub expanded_bytes: u64, pub probe_scripts: BTreeMap, + pub signature: crate::trust::SignatureStatus, } pub struct PreparedPackage { pub preview: PackagePreview, pub directory: tempfile::TempDir, + pub(crate) provenance: crate::registry::Provenance, } /// Inspect and extract into a private staging directory. Nothing becomes active here. @@ -99,7 +101,10 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result< if name == "checksums.json" && bytes.len() > 256 * 1024 { return Err(invalid("Checksums document is too large")); } - if name != "checksums.json" { + if name == "signature.json" && bytes.len() > 4096 { + return Err(invalid("Signature document is too large")); + } + if name != "checksums.json" && name != "signature.json" { hashes.insert(name.clone(), hex::encode(Sha256::digest(&bytes))); } if let Some(parent) = target.parent() { @@ -114,6 +119,7 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result< "Plugin package checksums do not match its complete contents", )); } + let signature = crate::trust::inspect(directory.path(), crate::trust::OFFICIAL_PLUGIN_KEYS)?; let manifest = Manifest::parse( &std::fs::read(directory.path().join("manifest.json"))?, app_version, @@ -146,11 +152,13 @@ pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result< } } Ok(PreparedPackage { + provenance: crate::registry::Provenance::Local, preview: PackagePreview { manifest, digest: package_digest, expanded_bytes, probe_scripts, + signature, }, directory, }) @@ -188,6 +196,7 @@ pub(crate) mod tests { entries.extend_from_slice(extra); let checksums = entries .iter() + .filter(|(name, _)| *name != "signature.json") .map(|(name, bytes)| (name.to_string(), hex::encode(Sha256::digest(bytes)))) .collect::>(); let mut zip = zip::ZipWriter::new(File::create(path).unwrap()); diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs index 7b99deb5..c3149c4f 100644 --- a/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/registry.rs @@ -6,11 +6,52 @@ use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "source", rename_all = "camelCase", deny_unknown_fields)] +pub enum Provenance { + #[default] + Local, + Marketplace { + #[serde(rename = "repositoryId")] + repository_id: String, + publisher: String, + #[serde(rename = "signingKeyId")] + signing_key_id: String, + #[serde(rename = "packageSha256")] + package_sha256: String, + }, +} + +impl Provenance { + fn continuous_with(&self, other: &Self) -> bool { + match (self, other) { + (Self::Local, Self::Local) => true, + ( + Self::Marketplace { + repository_id: a, + publisher: p, + .. + }, + Self::Marketplace { + repository_id: b, + publisher: q, + .. + }, + ) => a == b && p == q, + _ => false, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct InstalledVersion { pub manifest: Manifest, pub digest: String, + #[serde(default)] + pub provenance: Provenance, + #[serde(default)] + pub signature: crate::trust::SignatureStatus, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -91,6 +132,23 @@ impl Registry { if record.manifest.id != *id || record.manifest.version != *version { return Err(invalid("Invalid plugin version record")); } + if let Provenance::Marketplace { + repository_id, + publisher, + signing_key_id, + package_sha256, + } = &record.provenance + && (repository_id != crate::marketplace::REPOSITORY_ID + || publisher != &record.manifest.publisher + || package_sha256 != &record.digest + || !valid_id(signing_key_id) + || record.signature + != (crate::trust::SignatureStatus::Verified { + key_id: signing_key_id.clone(), + })) + { + return Err(invalid("Invalid marketplace provenance record")); + } } } Ok(Self { @@ -153,6 +211,8 @@ impl Registry { let record = InstalledVersion { manifest: package.preview.manifest.clone(), digest: package.preview.digest.clone(), + provenance: package.provenance.clone(), + signature: package.preview.signature.clone(), }; let id = record.manifest.id.clone(); let version = record.manifest.version.clone(); @@ -175,9 +235,18 @@ impl Registry { "Plugin publisher changed; uninstall before installing from a new publisher", )); } + if plugin + .versions + .values() + .any(|v| !v.provenance.continuous_with(&record.provenance)) + { + return Err(invalid( + "Plugin source changed; uninstall before installing from a different repository or local source", + )); + } let mut moved_target = None; if let Some(existing) = plugin.versions.get(&version) { - if existing.digest != record.digest { + if existing.digest != record.digest || existing.provenance != record.provenance { return Err(invalid( "An installed version cannot be replaced by different content", )); @@ -324,6 +393,37 @@ mod tests { use super::*; use crate::package::{prepare, tests::package}; + #[test] + fn legacy_records_migrate_to_local_provenance() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("plugins"); + let path = temp.path().join("test.nyap"); + package(&path, &[]); + let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap(); + let prepared = prepare(&path, &root, "1.2.12").unwrap(); + let digest = prepared.preview.digest.clone(); + registry.install(prepared, &digest).unwrap(); + let index = root.join("registry.json"); + let mut json: serde_json::Value = + serde_json::from_slice(&std::fs::read(&index).unwrap()).unwrap(); + let record = json["example.tools"]["versions"]["1.0.0"] + .as_object_mut() + .unwrap(); + record.remove("provenance"); + record.remove("signature"); + std::fs::write(index, serde_json::to_vec(&json).unwrap()).unwrap(); + let migrated = Registry::open(root, "1.2.12".into()).unwrap(); + assert_eq!( + migrated + .get("example.tools") + .unwrap() + .active() + .unwrap() + .provenance, + Provenance::Local + ); + } + #[test] fn updates_rollback_and_revocation_require_fresh_activation() { use crate::package::tests::package_manifest; diff --git a/src-tauri/crates/nyaterm-plugin-runtime/src/trust.rs b/src-tauri/crates/nyaterm-plugin-runtime/src/trust.rs new file mode 100644 index 00000000..a910843e --- /dev/null +++ b/src-tauri/crates/nyaterm-plugin-runtime/src/trust.rs @@ -0,0 +1,114 @@ +//! Repository keys are pinned in the client. Catalogs cannot extend this trust store. +use crate::{Result, invalid}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signature, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub const OFFICIAL_PLUGIN_KEYS: &[(&str, &str)] = &[]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PackageSignature { + pub algorithm: String, + pub key_id: String, + pub signature: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "status", rename_all = "camelCase")] +pub enum SignatureStatus { + #[default] + Unsigned, + Untrusted { + #[serde(rename = "keyId")] + key_id: String, + }, + Verified { + #[serde(rename = "keyId")] + key_id: String, + }, +} + +pub fn verify(bytes: &[u8], signature: &PackageSignature, keys: &[(&str, &str)]) -> Result<()> { + if signature.algorithm != "ed25519" { + return Err(invalid("Unsupported plugin signature algorithm")); + } + let public = keys + .iter() + .find(|(id, _)| *id == signature.key_id) + .ok_or_else(|| invalid("Plugin signing key is not trusted"))? + .1; + let public: [u8; 32] = STANDARD + .decode(public) + .map_err(|_| invalid("Invalid trusted public key"))? + .try_into() + .map_err(|_| invalid("Invalid trusted public key length"))?; + let key = + VerifyingKey::from_bytes(&public).map_err(|_| invalid("Invalid trusted public key"))?; + let raw = STANDARD + .decode(&signature.signature) + .map_err(|_| invalid("Invalid plugin signature encoding"))?; + let signature = + Signature::from_slice(&raw).map_err(|_| invalid("Invalid plugin signature length"))?; + key.verify_strict(bytes, &signature) + .map_err(|_| invalid("Plugin signature verification failed")) +} + +pub fn inspect(root: &Path, keys: &[(&str, &str)]) -> Result { + let path = root.join("signature.json"); + if !path.exists() { + return Ok(SignatureStatus::Unsigned); + } + let signature: PackageSignature = serde_json::from_slice(&std::fs::read(path)?)?; + if signature.algorithm != "ed25519" + || signature.key_id.is_empty() + || signature.key_id.len() > 128 + { + return Err(invalid("Invalid plugin signature metadata")); + } + let raw = STANDARD + .decode(&signature.signature) + .map_err(|_| invalid("Invalid plugin signature encoding"))?; + if raw.len() != 64 { + return Err(invalid("Invalid plugin signature length")); + } + if !keys.iter().any(|(id, _)| *id == signature.key_id) { + return Ok(SignatureStatus::Untrusted { + key_id: signature.key_id, + }); + } + verify( + &std::fs::read(root.join("checksums.json"))?, + &signature, + keys, + )?; + Ok(SignatureStatus::Verified { + key_id: signature.key_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + #[test] + fn verifies_exact_bytes_and_rejects_wrong_key_algorithm_and_signature() { + let key = SigningKey::from_bytes(&[42; 32]); + let public = STANDARD.encode(key.verifying_key().to_bytes()); + let keys = [("test", public.as_str())]; + let mut signature = PackageSignature { + algorithm: "ed25519".into(), + key_id: "test".into(), + signature: STANDARD.encode(key.sign(b"checksums").to_bytes()), + }; + verify(b"checksums", &signature, &keys).unwrap(); + assert!(verify(b"checksums\n", &signature, &keys).is_err()); + assert!(verify(b"checksums", &signature, &[]).is_err()); + signature.algorithm = "rsa".into(); + assert!(verify(b"checksums", &signature, &keys).is_err()); + signature.algorithm = "ed25519".into(); + signature.signature = STANDARD.encode([0; 64]); + assert!(verify(b"checksums", &signature, &keys).is_err()); + } +} diff --git a/src-tauri/src/cmd/plugins.rs b/src-tauri/src/cmd/plugins.rs index 9a9b84c0..9b82e8fc 100644 --- a/src-tauri/src/cmd/plugins.rs +++ b/src-tauri/src/cmd/plugins.rs @@ -6,6 +6,50 @@ use serde_json::Value; use std::path::PathBuf; use std::sync::Arc; +#[tauri::command] +pub async fn get_plugin_marketplace( + manager: tauri::State<'_, Arc>, +) -> AppResult { + manager.marketplace_catalog().await +} + +#[tauri::command] +pub async fn inspect_marketplace_plugin( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + plugin_id: String, + version: String, + expected_sha256: String, +) -> AppResult { + manager + .inspect_marketplace(&app, window.label(), &plugin_id, &version, &expected_sha256) + .await +} + +#[tauri::command] +pub async fn install_marketplace_plugin( + app: tauri::AppHandle, + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + token: String, +) -> AppResult { + manager + .install_marketplace(&app, window.label(), &token) + .await +} + +#[tauri::command] +pub async fn cancel_marketplace_plugin_review( + window: tauri::WebviewWindow, + manager: tauri::State<'_, Arc>, + token: String, +) -> AppResult<()> { + manager + .cancel_marketplace_review(window.label(), &token) + .await +} + #[tauri::command] pub async fn list_plugins( manager: tauri::State<'_, Arc>, diff --git a/src-tauri/src/core/plugins/marketplace.rs b/src-tauri/src/core/plugins/marketplace.rs new file mode 100644 index 00000000..9e08b573 --- /dev/null +++ b/src-tauri/src/core/plugins/marketplace.rs @@ -0,0 +1,247 @@ +use super::{PluginManager, ensure_unlocked, plugin_error}; +use crate::error::{AppError, AppResult}; +use nyaterm_plugin_runtime::marketplace::{self, Artifact, Catalog, CatalogPlugin, CatalogVersion}; +use nyaterm_plugin_runtime::package::{PackagePreview, PreparedPackage, prepare}; +use nyaterm_plugin_runtime::registry::InstalledPlugin; +use nyaterm_plugin_runtime::trust::OFFICIAL_PLUGIN_KEYS; +use serde::Serialize; +use std::time::{Duration, Instant}; +use tauri::Emitter; +use tokio::io::AsyncWriteExt; + +pub(super) struct Review { + pub window_label: String, + expires: Instant, + package: PreparedPackage, + artifact: Artifact, + plugin: CatalogPlugin, + version: CatalogVersion, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketplaceCatalog { + catalog: Catalog, + target: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MarketplacePreview { + token: String, + package: PackagePreview, + repository_id: &'static str, +} + +fn network_error(error: impl std::fmt::Display) -> AppError { + AppError::Config(format!("Plugin Store: {error}")) +} + +fn client() -> AppResult { + reqwest::Client::builder() + .https_only(true) + .user_agent(concat!("NyaTerm/", env!("CARGO_PKG_VERSION"))) + .connect_timeout(Duration::from_secs(15)) + .timeout(Duration::from_mins(2)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(network_error) +} + +async fn catalog() -> AppResult { + let mut response = client()? + .get(marketplace::CATALOG_URL) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)?; + if response + .content_length() + .is_some_and(|n| n > marketplace::MAX_CATALOG_BYTES as u64) + { + return Err(network_error("Catalog exceeds size limit")); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(network_error)? { + if bytes.len() + chunk.len() > marketplace::MAX_CATALOG_BYTES { + return Err(network_error("Catalog exceeds size limit")); + } + bytes.extend_from_slice(&chunk); + } + Catalog::parse(&bytes).map_err(plugin_error) +} + +fn main_window(label: &str) -> AppResult<()> { + if !crate::window_state::is_main_window_label(label) { + return Err(network_error("Store installs require a main window")); + } + Ok(()) +} + +impl PluginManager { + pub async fn marketplace_catalog(&self) -> AppResult { + Ok(MarketplaceCatalog { + catalog: catalog().await?, + target: marketplace::current_target(), + }) + } + + pub async fn inspect_marketplace( + &self, + app: &tauri::AppHandle, + window: &str, + id: &str, + version: &str, + expected_sha256: &str, + ) -> AppResult { + ensure_unlocked(app)?; + main_window(window)?; + // Serialize downloads so the package and pending-review quotas also bound memory/disk usage. + let mut reviews = self.marketplace_reviews.lock().await; + reviews.retain(|_, review| review.expires > Instant::now()); + if reviews.len() >= 8 { + return Err(network_error("Too many pending Store reviews")); + } + let catalog = catalog().await?; + let (plugin, version, artifact) = catalog + .select(id, version, &marketplace::current_target()) + .map_err(plugin_error)?; + if artifact.sha256 != expected_sha256 { + return Err(network_error("Catalog changed; refresh and review again")); + } + if !OFFICIAL_PLUGIN_KEYS + .iter() + .any(|(id, _)| *id == artifact.signing_key_id) + { + return Err(network_error( + "Store signing key is not trusted by this NyaTerm version", + )); + } + marketplace::https_url(&artifact.url).map_err(plugin_error)?; + std::fs::create_dir_all(&self.root).map_err(network_error)?; + let download = tempfile::NamedTempFile::new_in(&self.root).map_err(network_error)?; + let mut output = tokio::fs::File::from_std(download.reopen().map_err(network_error)?); + let mut response = client()? + .get(&artifact.url) + .send() + .await + .map_err(network_error)? + .error_for_status() + .map_err(network_error)?; + if response + .content_length() + .is_some_and(|n| n != artifact.size) + { + return Err(network_error("Store package size mismatch")); + } + let mut size = 0_u64; + while let Some(chunk) = response.chunk().await.map_err(network_error)? { + size = size + .checked_add(chunk.len() as u64) + .ok_or_else(|| network_error("Package size overflow"))?; + if size > artifact.size { + return Err(network_error("Store download exceeds catalog size")); + } + output.write_all(&chunk).await.map_err(network_error)?; + } + output.flush().await.map_err(network_error)?; + drop(output); + let root = self.root.clone(); + let app_version = self.app_version.clone(); + let p = plugin.clone(); + let v = version.clone(); + let a = artifact.clone(); + let package = tokio::task::spawn_blocking(move || { + let mut package = prepare(download.path(), &root, &app_version)?; + marketplace::validate_package(&mut package, &p, &v, &a, size, OFFICIAL_PLUGIN_KEYS)?; + Ok::<_, nyaterm_plugin_runtime::Error>(package) + }) + .await + .map_err(network_error)? + .map_err(plugin_error)?; + ensure_unlocked(app)?; + let token = uuid::Uuid::new_v4().to_string(); + let preview = MarketplacePreview { + token: token.clone(), + package: package.preview.clone(), + repository_id: marketplace::REPOSITORY_ID, + }; + reviews.insert( + token, + Review { + window_label: window.into(), + expires: Instant::now() + Duration::from_mins(10), + package, + artifact: artifact.clone(), + plugin: plugin.clone(), + version: version.clone(), + }, + ); + Ok(preview) + } + + pub async fn cancel_marketplace_review(&self, window: &str, token: &str) -> AppResult<()> { + let mut reviews = self.marketplace_reviews.lock().await; + if reviews + .get(token) + .is_some_and(|review| review.window_label != window) + { + return Err(network_error("Review belongs to another window")); + } + reviews.remove(token); + Ok(()) + } + + pub async fn install_marketplace( + &self, + app: &tauri::AppHandle, + window: &str, + token: &str, + ) -> AppResult { + ensure_unlocked(app)?; + main_window(window)?; + let review = { + let mut reviews = self.marketplace_reviews.lock().await; + if reviews.get(token).is_none_or(|review| { + review.window_label != window || review.expires <= Instant::now() + }) { + return Err(network_error( + "Store review expired or belongs to another window", + )); + } + reviews.remove(token).unwrap() + }; + // A removed/changed artifact cannot be installed from an old review. + let current = catalog().await?; + let (plugin, version, artifact) = current + .select( + &review.plugin.id, + &review.version.version, + &marketplace::current_target(), + ) + .map_err(plugin_error)?; + if artifact.sha256 != review.artifact.sha256 + || artifact.signing_key_id != review.artifact.signing_key_id + || version.permissions != review.version.permissions + || plugin.publisher != review.plugin.publisher + { + return Err(network_error( + "Store metadata changed; review the package again", + )); + } + let id = review.plugin.id; + ensure_unlocked(app)?; + let _lease = self.lifecycle.acquire(&id, true)?; + self.revoke_plugin(&id).await; + let mut registry = self.registry.write().await; + ensure_unlocked(app)?; + let result = registry + .as_mut() + .ok_or_else(|| self.registry_error())? + .install(review.package, &review.artifact.sha256) + .map_err(plugin_error)?; + let _ = app.emit("plugins-changed", ()); + Ok(result) + } +} diff --git a/src-tauri/src/core/plugins/mod.rs b/src-tauri/src/core/plugins/mod.rs index f94aaeb8..98845a14 100644 --- a/src-tauri/src/core/plugins/mod.rs +++ b/src-tauri/src/core/plugins/mod.rs @@ -1,5 +1,7 @@ mod gateway; mod lifecycle; +mod marketplace; +pub use marketplace::{MarketplaceCatalog, MarketplacePreview}; mod monitoring; pub use monitoring::MonitorSubscription; mod probe; @@ -101,6 +103,7 @@ pub struct PluginManager { storage_lock: Mutex<()>, diagnostics: Diagnostics, monitors: std::sync::Mutex, + marketplace_reviews: Mutex>, } impl PluginManager { @@ -121,6 +124,7 @@ impl PluginManager { storage_lock: Mutex::new(()), diagnostics: Diagnostics::default(), monitors: std::sync::Mutex::new(monitoring::Monitors::default()), + marketplace_reviews: Mutex::new(HashMap::new()), }) } @@ -320,6 +324,10 @@ impl PluginManager { } pub async fn revoke_window(&self, label: &str) { + self.marketplace_reviews + .lock() + .await + .retain(|_, review| review.window_label != label); self.monitors.lock().unwrap().revoke(|k| k.window == label); self.scopes.lock().await.retain(|_, scope| { if scope.window_label == label { @@ -351,6 +359,7 @@ impl PluginManager { } pub async fn revoke_all(&self) { + self.marketplace_reviews.lock().await.clear(); self.monitors.lock().unwrap().revoke(|_| true); for (_, scope) in self.scopes.lock().await.drain() { scope.cancellation.cancel(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 95a27767..6e8d9f36 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,10 @@ pub fn run() { cmd::plugins::clear_plugin_logs, cmd::plugins::stop_plugin_backend, cmd::plugins::list_plugins, + cmd::plugins::get_plugin_marketplace, + cmd::plugins::inspect_marketplace_plugin, + cmd::plugins::install_marketplace_plugin, + cmd::plugins::cancel_marketplace_plugin_review, cmd::plugins::inspect_plugin_package, cmd::plugins::install_plugin_package, cmd::plugins::configure_plugin, diff --git a/src/components/plugins/PluginDetails.tsx b/src/components/plugins/PluginDetails.tsx new file mode 100644 index 00000000..bdbbf03c --- /dev/null +++ b/src/components/plugins/PluginDetails.tsx @@ -0,0 +1,96 @@ +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { getErrorMessage } from "@/lib/errors"; +import type { MarketplacePlugin, MarketplacePreview } from "@/types/plugins"; + +export function PluginDetails({ + plugin, + preview, + busy, + locked, + onCancel, + onInstall, +}: { + plugin: MarketplacePlugin; + preview: MarketplacePreview; + busy: boolean; + locked: boolean; + onCancel: () => void; + onInstall: () => void; +}) { + const { t } = useTranslation(); + const { manifest, signature } = preview.package; + const version = plugin.versions.find((v) => v.version === manifest.version); + return ( + !open && !busy && onCancel()}> + + + + {manifest.name} · {manifest.version} + + {t("plugins.reviewDescription")} + +
+

{manifest.description}

+

+ {t("plugins.publisher", { name: manifest.publisher })}{" "} + {plugin.verified && ( + {t("plugins.store.publisherVerified")} + )} +

+

+ {t("plugins.store.repository")}: {t("plugins.store.official")} +

+ {t(`plugins.store.${signature.status}`)} + +

{plugin.license}

+
+

{t("plugins.permissionsTitle")}

+ {manifest.permissions.map((permission) => ( +

+ {permission.startsWith("network:") + ? t("plugins.permissions.network", { origin: permission.slice(8) }) + : t(`plugins.permissions.${permission.split(".").join("_")}`)} +

+ ))} +
+ {manifest.backend && ( +

+ {t("plugins.nativeTrust")} +

+ )} + {version?.releaseNotes && ( +

{version.releaseNotes}

+ )} +
+ + + + +
+
+ ); +} diff --git a/src/components/plugins/PluginMarketplace.test.tsx b/src/components/plugins/PluginMarketplace.test.tsx new file mode 100644 index 00000000..66934be8 --- /dev/null +++ b/src/components/plugins/PluginMarketplace.test.tsx @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { pluginApi } from "@/lib/plugins"; +import type { MarketplaceCatalog, MarketplacePreview } from "@/types/plugins"; +import { PluginMarketplace } from "./PluginMarketplace"; + +const context = vi.hoisted(() => ({ plugins: [], locked: false, refresh: vi.fn() })); +vi.mock("@/context/PluginContext", () => ({ usePlugins: () => context })); +vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn(), success: vi.fn() } })); +vi.mock("@/lib/plugins", () => ({ + pluginApi: { + marketplace: vi.fn(), + inspectMarketplace: vi.fn(), + installMarketplace: vi.fn(), + cancelMarketplaceReview: vi.fn(), + }, +})); +const data: MarketplaceCatalog = { + target: "windows-x86_64", + catalog: { + catalogVersion: 1, + repository: { id: "nyaterm-official", name: "Store" }, + generatedAt: "2026-10-03T00:00:00Z", + plugins: [ + { + id: "example.tools", + name: "Tools", + description: "Session tools", + publisher: "example", + verified: true, + tags: ["session"], + source: "https://github.com/example/tools", + homepage: "https://github.com/example/tools", + license: "MIT", + permissions: ["session.read"], + latestVersion: "1.0.0", + versions: [ + { + version: "1.0.0", + releasedAt: "2026-10-03T00:00:00Z", + releaseNotes: "Initial release", + permissions: ["session.read"], + artifacts: [ + { + target: "universal", + url: "https://example.com/plugin.nyap", + sha256: "a".repeat(64), + size: 123, + signingKeyId: "test", + }, + ], + }, + ], + }, + ], + }, +}; +const preview: MarketplacePreview = { + token: "review-token", + repositoryId: "nyaterm-official", + package: { + digest: "a".repeat(64), + expandedBytes: 1000, + signature: { status: "verified", keyId: "test" }, + manifest: { + manifestVersion: 1, + id: "example.tools", + name: "Tools", + description: "Session tools", + publisher: "example", + version: "1.0.0", + engine: ">=1.0.0", + permissions: ["session.read"], + contributions: { panels: [], commands: [] }, + }, + }, +}; +beforeEach(() => { + vi.clearAllMocks(); + context.locked = false; + vi.mocked(pluginApi.marketplace).mockResolvedValue(data); + vi.mocked(pluginApi.inspectMarketplace).mockResolvedValue(preview); + vi.mocked(pluginApi.cancelMarketplaceReview).mockResolvedValue(); +}); +afterEach(cleanup); +describe("Marketplace review and installation", () => { + it("installs only after verified package review and does not grant runtime permissions", async () => { + render(); + fireEvent.click(await screen.findByText("plugins.install")); + await screen.findByText("plugins.store.verified"); + expect(pluginApi.inspectMarketplace).toHaveBeenCalledWith( + "example.tools", + "1.0.0", + "a".repeat(64), + ); + expect(pluginApi.installMarketplace).not.toHaveBeenCalled(); + const installButtons = screen.getAllByText("plugins.install"); + fireEvent.click(installButtons[installButtons.length - 1]); + await waitFor(() => expect(pluginApi.installMarketplace).toHaveBeenCalledWith("review-token")); + await waitFor(() => expect(context.refresh).toHaveBeenCalled()); + }); + it("discards a pending review when cancelled and filters by plugin metadata", async () => { + render(); + await screen.findByText("Tools"); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "missing" } }); + expect(screen.queryByText("Tools")).toBeNull(); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "session" } }); + fireEvent.click(screen.getByText("plugins.install")); + await screen.findByText("plugins.store.verified"); + fireEvent.click(screen.getByText("plugins.cancel")); + expect(pluginApi.cancelMarketplaceReview).toHaveBeenCalledWith("review-token"); + expect(pluginApi.installMarketplace).not.toHaveBeenCalled(); + }); + it("does not allow installation after backend verification fails", async () => { + vi.mocked(pluginApi.inspectMarketplace).mockRejectedValue(new Error("Signature failed")); + render(); + fireEvent.click(await screen.findByText("plugins.install")); + await waitFor(() => expect(pluginApi.inspectMarketplace).toHaveBeenCalled()); + expect(screen.queryByText("plugins.store.verified")).toBeNull(); + expect(pluginApi.installMarketplace).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/plugins/PluginMarketplace.tsx b/src/components/plugins/PluginMarketplace.tsx new file mode 100644 index 00000000..21221230 --- /dev/null +++ b/src/components/plugins/PluginMarketplace.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { usePlugins } from "@/context/PluginContext"; +import { getErrorMessage } from "@/lib/errors"; +import { pluginApi } from "@/lib/plugins"; +import type { MarketplaceCatalog, MarketplacePlugin, MarketplacePreview } from "@/types/plugins"; +import { PluginDetails } from "./PluginDetails"; + +export function PluginMarketplace() { + const { t } = useTranslation(); + const { plugins, refresh, locked } = usePlugins(); + const [data, setData] = useState(null); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [review, setReview] = useState<{ + plugin: MarketplacePlugin; + preview: MarketplacePreview; + } | null>(null); + const mounted = useRef(false); + const token = useRef(null); + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const catalog = await pluginApi.marketplace(); + if (mounted.current) setData(catalog); + } catch (error) { + if (mounted.current) setError(getErrorMessage(error)); + } finally { + if (mounted.current) setLoading(false); + } + }, []); + useEffect(() => { + mounted.current = true; + void load(); + return () => { + mounted.current = false; + if (token.current) void pluginApi.cancelMarketplaceReview(token.current).catch(() => {}); + token.current = null; + }; + }, [load]); + const cancel = useCallback(() => { + if (token.current) void pluginApi.cancelMarketplaceReview(token.current).catch(() => {}); + token.current = null; + setReview(null); + }, []); + useEffect(() => { + if (locked) cancel(); + }, [locked, cancel]); + const inspect = async (plugin: MarketplacePlugin, sha256: string) => { + setBusy(true); + try { + const preview = await pluginApi.inspectMarketplace(plugin.id, plugin.latestVersion, sha256); + if (!mounted.current) { + await pluginApi.cancelMarketplaceReview(preview.token); + return; + } + token.current = preview.token; + setReview({ plugin, preview }); + } catch (error) { + if (mounted.current) toast.error(getErrorMessage(error)); + } finally { + if (mounted.current) setBusy(false); + } + }; + const install = async () => { + if (!review) return; + setBusy(true); + try { + await pluginApi.installMarketplace(review.preview.token); + await refresh(); + toast.success(t("plugins.installed")); + } catch (error) { + toast.error(getErrorMessage(error)); + } finally { + token.current = null; + if (mounted.current) { + setReview(null); + setBusy(false); + } + } + }; + const matches = + data?.catalog.plugins.filter((p) => + `${p.name} ${p.id} ${p.publisher} ${p.description} ${p.tags.join(" ")}` + .toLocaleLowerCase() + .includes(query.trim().toLocaleLowerCase()), + ) ?? []; + return ( +
+
+

{t("plugins.store.official")}

+ +
+ setQuery(event.target.value)} + placeholder={t("plugins.store.search")} + aria-label={t("plugins.store.search")} + /> + {loading &&

{t("plugins.loading")}

} + {error && ( +

+ {error} +

+ )} + {!loading && !error && !matches.length && ( +

+ {t("plugins.store.empty")} +

+ )} + {!loading && + !error && + matches.map((plugin) => { + const version = plugin.versions.find((v) => v.version === plugin.latestVersion); + const artifact = + version?.artifacts.find((a) => a.target === data?.target) ?? + version?.artifacts.find((a) => a.target === "universal"); + const installed = plugins.find((p) => p.id === plugin.id); + const current = installed?.activeVersion === plugin.latestVersion; + const local = + installed && + installed.versions[installed.activeVersion]?.provenance?.source !== "marketplace"; + return ( +
+

+ {plugin.name}{" "} + + {plugin.latestVersion} + +

+

+ {t("plugins.publisher", { name: plugin.publisher })}{" "} + {plugin.verified && ( + {t("plugins.store.publisherVerified")} + )} +

+

{plugin.description}

+
+ {plugin.tags.map((tag) => ( + + {tag} + + ))} + {version?.permissions.includes("native") && ( + {t("plugins.native")} + )} +
+ {local && ( +

{t("plugins.store.sourceConflict")}

+ )} + +
+ ); + })} + {review && ( + void install()} + /> + )} +
+ ); +} diff --git a/src/components/plugins/PluginsPanel.tsx b/src/components/plugins/PluginsPanel.tsx index cab51860..14b94fce 100644 --- a/src/components/plugins/PluginsPanel.tsx +++ b/src/components/plugins/PluginsPanel.tsx @@ -31,13 +31,33 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { usePlugins } from "@/context/PluginContext"; import { getErrorMessage } from "@/lib/errors"; import { activeManifest, openPlugin, pluginApi } from "@/lib/plugins"; import type { InstalledPlugin, PluginPackagePreview } from "@/types/plugins"; import { PluginDiagnostics } from "./PluginDiagnostics"; +import { PluginMarketplace } from "./PluginMarketplace"; export function PluginsPanel({ sessionId }: { sessionId: string | null }) { + const { t } = useTranslation(); + return ( + + + {t("plugins.store.marketplace")} + {t("plugins.store.installedTab")} + + + + + + + + + ); +} + +function InstalledPluginsPanel({ sessionId }: { sessionId: string | null }) { const { t } = useTranslation(); const { plugins, loaded, error, refresh, locked } = usePlugins(); const [busy, setBusy] = useState(false); @@ -132,6 +152,18 @@ export function PluginsPanel({ sessionId }: { sessionId: string | null }) { {t(plugin.enabled ? "plugins.enabled" : "plugins.disabled")} {manifest.backend && {t("plugins.native")}} + + {t( + plugin.versions[plugin.activeVersion]?.provenance?.source === "marketplace" + ? "plugins.store.marketplace" + : "plugins.store.local", + )} + + + {t( + `plugins.store.${plugin.versions[plugin.activeVersion]?.signature?.status ?? "unsigned"}`, + )} +

{manifest.description}

@@ -270,6 +302,10 @@ export function PluginsPanel({ sessionId }: { sessionId: string | null }) { })}

{t("plugins.publisherUnverified")}

+ {t("plugins.store.local")} + + {t(`plugins.store.${preview.package.signature.status}`)} +

{t("plugins.permissionsTitle")}

{preview.package.manifest.permissions.map((permission) => ( diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 29c8408a..5fe2e48c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -52,6 +52,7 @@ "actionName": "Action name", "actionPrompt": "Action prompt", "activeProfile": "Active Profile", + "addProvider": "Add provider", "agentApproveExecute": "Execute", "agentAwaitingApproval": "Awaiting approval...", "agentCommandExecutionMode": "Command execution policy", @@ -94,6 +95,7 @@ "backgroundAgentExecutionDesc": "Run commands as background tasks and return results to the current AI chat.", "baseUrl": "API Base URL", "baseUrlRootHint": "Enter the API root URL, not a specific chat request path.", + "changeProviderIcon": "Change provider icon", "claudeCodeAuthMode": "Auth mode", "claudeCodeConfigDirectory": "Config directory", "claudeCodeConnected": "Available", @@ -145,10 +147,22 @@ "commandTargetLabel": "Target: {{target}}", "commandTargetMissing": "Command target missing", "commandTargetUnavailable": "Target terminal is unavailable", + "confirmAddProvider": "Confirm add", + "connectionModelCount": "{{count}} models", + "connectionStatus": { + "error": "Connection failed", + "idle": "Not tested", + "success": "Connected", + "testing": "Testing connection…" + }, + "connectionTest": "Test connection", + "connectionTestSucceeded": "Provider connection succeeded", + "connectionTestUnsupported": "Connection testing currently supports OpenAI-compatible providers only", "contextLineLimit": "Default Context Lines", "copied": "Copied", "copy": "Copy", "customProvider": "Custom Provider", + "customProviderOption": "Custom provider", "dateEarlier": "Earlier", "dateLast7Days": "Last 7 Days", "dateToday": "Today", @@ -156,9 +170,13 @@ "defaultSession": "Current session (default)", "deleteManualModel": "Delete manual model", "deleteModel": "Delete model", + "deleteProvider": "Delete provider", + "deleteProviderConfirm": "Delete {{name}}? Its associated model configurations will also be removed.", "deleteSession": "Delete", "detect": "Detect", "disabled": "AI is disabled", + "discardProviderConfirm": "Discard {{name}}? The values you entered will be lost.", + "editModelConfig": "Edit model configuration", "empty": "Ask AI to explain output or generate commands.", "enableAutoExecution": "Enable auto", "enableFullAccess": "Enable full access", @@ -209,6 +227,7 @@ "generate": "Generate", "generateCommand": "Generate Command", "goToSettingsToEnable": "Enable in Settings", + "hideApiKey": "Hide API key", "history": "History", "historyCurrentTerminal": "Current Terminal", "historyInUse": "Already open in another terminal", @@ -235,7 +254,6 @@ }, "manualModelAdded": "Model added: {{model}}", "manualModelBadge": "Manual", - "providerReturnedBadge": "Provider returned", "manualModelDeleted": "Model deleted: {{model}}", "manualModelExists": "Model already exists: {{model}}", "manualModelNoProvider": "Enable an API key profile before adding a model manually.", @@ -253,40 +271,13 @@ "model": "Model", "modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.", "modelList": "Models from this provider", - "editModelConfig": "Edit model configuration", - "supportedReasoningEfforts": "Supported reasoning effort", "modelListDesc": "Refresh pulls known models from rust-genai. New models are disabled by default.", - "providerList": "Provider list", - "addProvider": "Add provider", - "changeProviderIcon": "Change provider icon", - "deleteProvider": "Delete provider", - "deleteProviderConfirm": "Delete {{name}}? Its associated model configurations will also be removed.", - "discardProviderConfirm": "Discard {{name}}? The values you entered will be lost.", - "customProviderOption": "Custom provider", - "confirmAddProvider": "Confirm add", - "providerEndpoint": "API endpoint", - "providerEndpointRequired": "Enter an API endpoint", - "providerApiKeyRequired": "Enter an API key", - "providerNameDuplicate": "A provider with this name already exists", - "providerProtocol": "API protocol", - "providerProtocolChatCompletions": "/chat/completions (OpenAI-compatible)", - "providerProtocolResponses": "/responses (OpenAI Responses API)", - "providerProtocolAnthropic": "Anthropic Messages API", - "providerProtocolGemini": "Google Gemini API", - "providerProtocolOllama": "/api/chat (Ollama native)", - "connectionTest": "Test connection", - "connectionTestUnsupported": "Connection testing currently supports OpenAI-compatible providers only", - "connectionTestSucceeded": "Provider connection succeeded", - "connectionModelCount": "{{count}} models", - "connectionStatus": { - "idle": "Not tested", - "testing": "Testing connection…", - "success": "Connected", - "error": "Connection failed" - }, - "showApiKey": "Show API key", - "hideApiKey": "Hide API key", "modelSelect": "Model", + "modelTestFailed": "Model test failed: {{model}}", + "modelTestFailedShort": "Test failed", + "modelTestSucceeded": "Model test succeeded: {{model}}", + "modelTestSucceededShort": "Test succeeded", + "modelTestUnsupported": "Codex models cannot be tested here yet", "models": "Models", "modelsRefreshed": "Model list refreshed", "newChat": "New Chat", @@ -314,9 +305,21 @@ "permissionObserverDesc": "Allows basic reads, asks before sensitive reads, and blocks write operations.", "placeholder": "Ask anything... Type @ for sessions", "profileName": "Name", - "providerName": "Provider name", + "providerApiKeyRequired": "Enter an API key", + "providerEndpoint": "API endpoint", + "providerEndpointRequired": "Enter an API endpoint", "providerKind": "Provider", + "providerList": "Provider list", + "providerName": "Provider name", + "providerNameDuplicate": "A provider with this name already exists", "providerProfiles": "Profiles", + "providerProtocol": "API protocol", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolChatCompletions": "/chat/completions (OpenAI-compatible)", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat (Ollama native)", + "providerProtocolResponses": "/responses (OpenAI Responses API)", + "providerReturnedBadge": "Provider returned", "quickCommandCategory": "AI Generated", "quickPrompt": "Example: find top CPU processes", "quote": "Quote", @@ -352,27 +355,24 @@ "saveQuickCommand": "Save", "savedQuickCommand": "Saved to Quick Commands", "searchModels": "Search models", - "testModel": "Test model", - "modelTestSucceeded": "Model test succeeded: {{model}}", - "modelTestFailed": "Model test failed: {{model}}", - "modelTestSucceededShort": "Test succeeded", - "modelTestFailedShort": "Test failed", - "modelTestUnsupported": "Codex models cannot be tested here yet", "settings": "AI Settings", "setupAction": "Settings", "setupStep1": "Add an API key credential", "setupStep2": "Enable a model", "setupTitle": "AI Not Configured", + "showApiKey": "Show API key", "shownInTerminal": "Shown in terminal", "smartAutoExecuteMaxRisk": "Smart auto-execute max risk", "smartAutoExecuteMaxRiskDesc": "Commands auto-run when the locally reviewed risk is at or below this level. Critical always requires confirmation.", "stepCommand": "Command", "stepDuration": "{{ms}}ms", "stepExitCode": "exit {{code}}", + "supportedReasoningEfforts": "Supported reasoning effort", "targetSession": "Target", "terminalActions": "Terminal AI actions", "terminalOutputLines": "Output lines", "terminalOutputLinesDesc": "Lines shown in terminal when AI Agent runs a command. Set 0 to hide inline output.", + "testModel": "Test model", "thinking": "Thinking", "thoughtComplete": "Thought", "timeoutMs": "Timeout (ms)", @@ -1286,7 +1286,6 @@ "uploadStarted": "Uploading file \"{{name}}\"..." }, "gpuMonitor": { - "paused": "Monitoring paused. Refresh to resume.", "cuda": "CUDA", "details": "Details", "disabled": "Enable GPU monitoring in Settings", @@ -1305,6 +1304,7 @@ "noMatches": "No matching GPU processes", "noProcesses": "No active GPU compute processes", "noSession": "Connect via SSH to monitor NVIDIA GPUs", + "paused": "Monitoring paused. Refresh to resume.", "pid": "PID", "power": "Power", "search": "Search GPU processes...", @@ -1645,7 +1645,6 @@ "usernamePlaceholder": "e.g. root" }, "plugins": { - "probeTrust": "These scripts run automatically on the selected SSH session after authorization. Review their contents; third-party scripts are not guaranteed to be read-only. Updates require renewed authorization.", "allowOnce": "Allow once", "approvalDescription": "{{name}} requests access to the selected session.", "approvalTitle": "Approve plugin operation", @@ -1684,10 +1683,10 @@ "nativeTrust": "A native backend runs with your operating system user permissions. It can access files, networks and programs independently of these host permissions. Enable native code only from a publisher you trust.", "noInstalled": "No plugins installed.", "permissions": { - "remote_probe": "Automatically execute this plugin’s declared remote scripts", "filesystem_read": "Request text file reads in the selected session", "native": "Run this plugin’s native executable", "network": "Make HTTPS requests to {{origin}}", + "remote_probe": "Automatically execute this plugin’s declared remote scripts", "session_read": "Read the selected session’s name and type", "storage": "Store this plugin’s own settings", "terminal_execute": "Request command execution in the selected terminal", @@ -1695,6 +1694,7 @@ }, "permissionsDescription": "Choose which capabilities {{name}} may use. Terminal commands and file reads require approval for each operation.", "permissionsTitle": "Permissions", + "probeTrust": "These scripts run automatically on the selected SSH session after authorization. Review their contents; third-party scripts are not guaranteed to be read-only. Updates require renewed authorization.", "publisher": "Publisher: {{name}}", "publisherUnverified": "This publisher name is declared by the package and has not been verified.", "retry": "Retry", @@ -1702,6 +1702,24 @@ "reviewPackage": "Review plugin package", "running": "Running plugin command…", "saveAndEnable": "Save and enable", + "store": { + "empty": "No matching plugins in the Store.", + "installedTab": "Installed", + "local": "Local plugin", + "marketplace": "Marketplace", + "official": "NyaTerm Plugin Store", + "publisherVerified": "Verified publisher", + "refresh": "Refresh", + "repository": "Repository", + "search": "Search plugins…", + "source": "Source", + "sourceConflict": "Uninstall the local plugin before switching to the official Store.", + "unsigned": "Unsigned", + "unsupported": "Unsupported platform", + "untrusted": "Untrusted signing key", + "update": "Review update", + "verified": "Signature verified" + }, "title": "Plugins", "unavailable": "This plugin panel is disabled or unavailable. Its layout is preserved.", "uninstall": "Uninstall", @@ -2624,19 +2642,19 @@ "syncDebounceSeconds": "Auto Push Debounce (seconds)", "syncDebounceSecondsDesc": "Wait this long after the latest local change before uploading a sync snapshot.", "syncEnableFirst": "Enable cloud sync first.", + "syncGistCapacityHint": "Gitee Snippet may have reached its file limit. Cleaning old snapshots and pushing again usually recovers sync.", "syncMasterPasswordMissingDesc": "Set a master password first. Cloud sync stays disabled until one is configured.", "syncMasterPasswordReadyDesc": "Cloud sync uses your master password to encrypt the remote snapshot.", "syncProvider": "Provider", "syncProviderConfig": "Provider Configuration", "syncProviderConfigDesc": "Configure the remote storage used by cloud sync.", "syncProviderDesc": "Choose which remote storage backend OpenDAL should use.", + "syncPruneAndRetry": "Clean remote snapshots & retry", + "syncPruneAndRetrySuccess": "Remote snapshots cleaned and push succeeded.", "syncPullNow": "Pull Now", "syncPullSuccess": "Snapshot downloaded.", "syncPushNow": "Push Now", "syncPushSuccess": "Snapshot uploaded.", - "syncPruneAndRetry": "Clean remote snapshots & retry", - "syncPruneAndRetrySuccess": "Remote snapshots cleaned and push succeeded.", - "syncGistCapacityHint": "Gitee Snippet may have reached its file limit. Cleaning old snapshots and pushing again usually recovers sync.", "syncRecoverCurrentSuccess": "Remote metadata recovered.", "syncRemoteIncompleteTitle": "Incomplete Remote Sync Data", "syncResolveDownloadSuccess": "Remote version downloaded.", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 9a76c202..0b8d2119 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -52,6 +52,7 @@ "actionName": "작업 이름", "actionPrompt": "작업 프롬프트", "activeProfile": "활성 프로필", + "addProvider": "공급자 추가", "agentApproveExecute": "실행", "agentAwaitingApproval": "승인 대기 중...", "agentCommandExecutionMode": "명령 실행 정책", @@ -94,6 +95,7 @@ "backgroundAgentExecutionDesc": "명령을 백그라운드 작업으로 실행하고 결과를 현재 AI 채팅으로 반환합니다.", "baseUrl": "API 기본 URL", "baseUrlRootHint": "Enter the API root URL, not a specific chat request path.", + "changeProviderIcon": "공급자 아이콘 변경", "claudeCodeAuthMode": "인증 방식", "claudeCodeConfigDirectory": "설정 디렉터리", "claudeCodeConnected": "사용 가능", @@ -145,10 +147,22 @@ "commandTargetLabel": "대상: {{target}}", "commandTargetMissing": "명령 대상이 없습니다", "commandTargetUnavailable": "대상 터미널을 사용할 수 없습니다", + "confirmAddProvider": "추가 확인", + "connectionModelCount": "{{count}}개 모델", + "connectionStatus": { + "error": "연결 실패", + "idle": "테스트되지 않음", + "success": "연결됨", + "testing": "연결 테스트 중…" + }, + "connectionTest": "연결 테스트", + "connectionTestSucceeded": "공급자 연결에 성공했습니다", + "connectionTestUnsupported": "현재 OpenAI 호환 공급자만 연결 테스트를 지원합니다", "contextLineLimit": "기본 컨텍스트 줄 수", "copied": "복사됨", "copy": "복사", "customProvider": "사용자 지정 공급자", + "customProviderOption": "사용자 지정 / Custom", "dateEarlier": "이전", "dateLast7Days": "최근 7일", "dateToday": "오늘", @@ -156,9 +170,13 @@ "defaultSession": "현재 세션(기본값)", "deleteManualModel": "수동 모델 삭제", "deleteModel": "모델 삭제", + "deleteProvider": "공급자 삭제", + "deleteProviderConfirm": "{{name}} 공급자를 삭제하시겠습니까? 연결된 모델 설정도 함께 제거됩니다.", "deleteSession": "삭제", "detect": "감지", "disabled": "AI가 비활성화되어 있습니다", + "discardProviderConfirm": "{{name}} 공급자 추가를 취소하시겠습니까? 입력한 설정이 사라집니다.", + "editModelConfig": "모델 설정 편집", "empty": "AI에게 출력 설명이나 명령 생성을 요청하세요.", "enableAutoExecution": "자동 실행 켜기", "enableFullAccess": "전체 권한 사용", @@ -209,6 +227,7 @@ "generate": "생성", "generateCommand": "명령 생성", "goToSettingsToEnable": "설정에서 활성화", + "hideApiKey": "API 키 숨기기", "history": "기록", "historyCurrentTerminal": "현재 터미널", "historyInUse": "다른 터미널에서 열려 있음", @@ -235,7 +254,6 @@ }, "manualModelAdded": "모델 추가됨: {{model}}", "manualModelBadge": "수동", - "providerReturnedBadge": "공급자 반환", "manualModelDeleted": "모델 삭제됨: {{model}}", "manualModelExists": "이미 존재하는 모델입니다: {{model}}", "manualModelNoProvider": "모델을 수동으로 추가하려면 먼저 API 키 프로필을 활성화하세요.", @@ -253,40 +271,13 @@ "model": "모델", "modelDiscoveryUnsupported": "The current protocol does not support automatic model discovery yet.", "modelList": "이 공급자의 모델", - "editModelConfig": "모델 설정 편집", - "supportedReasoningEfforts": "지원되는 추론 강도", "modelListDesc": "새로고침하면 rust-genai에서 알려진 모델을 가져옵니다. 새 모델은 기본적으로 비활성화됩니다.", - "providerList": "공급자 목록", - "addProvider": "공급자 추가", - "changeProviderIcon": "공급자 아이콘 변경", - "deleteProvider": "공급자 삭제", - "deleteProviderConfirm": "{{name}} 공급자를 삭제하시겠습니까? 연결된 모델 설정도 함께 제거됩니다.", - "discardProviderConfirm": "{{name}} 공급자 추가를 취소하시겠습니까? 입력한 설정이 사라집니다.", - "customProviderOption": "사용자 지정 / Custom", - "confirmAddProvider": "추가 확인", - "providerEndpoint": "API 주소", - "providerEndpointRequired": "API 주소를 입력하세요", - "providerApiKeyRequired": "API 키를 입력하세요", - "providerNameDuplicate": "같은 이름의 공급자가 이미 있습니다", - "providerProtocol": "API 프로토콜", - "providerProtocolChatCompletions": "/chat/completions (OpenAI 호환 형식)", - "providerProtocolResponses": "/responses (OpenAI Responses API)", - "providerProtocolAnthropic": "Anthropic Messages API", - "providerProtocolGemini": "Google Gemini API", - "providerProtocolOllama": "/api/chat (Ollama 기본 프로토콜)", - "connectionTest": "연결 테스트", - "connectionTestUnsupported": "현재 OpenAI 호환 공급자만 연결 테스트를 지원합니다", - "connectionTestSucceeded": "공급자 연결에 성공했습니다", - "connectionModelCount": "{{count}}개 모델", - "connectionStatus": { - "idle": "테스트되지 않음", - "testing": "연결 테스트 중…", - "success": "연결됨", - "error": "연결 실패" - }, - "showApiKey": "API 키 표시", - "hideApiKey": "API 키 숨기기", "modelSelect": "모델", + "modelTestFailed": "모델 테스트 실패: {{model}}", + "modelTestFailedShort": "테스트 실패", + "modelTestSucceeded": "모델 테스트 성공: {{model}}", + "modelTestSucceededShort": "테스트 성공", + "modelTestUnsupported": "Codex 모델은 아직 여기서 테스트할 수 없습니다", "models": "모델", "modelsRefreshed": "모델 목록이 새로고침되었습니다", "newChat": "새 채팅", @@ -314,9 +305,21 @@ "permissionObserverDesc": "일반 읽기는 허용하고 민감한 읽기는 확인하며 쓰기 작업은 차단합니다.", "placeholder": "무엇이든 물어보세요... @를 입력하면 세션을 선택할 수 있습니다", "profileName": "이름", - "providerName": "공급자 이름", + "providerApiKeyRequired": "API 키를 입력하세요", + "providerEndpoint": "API 주소", + "providerEndpointRequired": "API 주소를 입력하세요", "providerKind": "공급자", + "providerList": "공급자 목록", + "providerName": "공급자 이름", + "providerNameDuplicate": "같은 이름의 공급자가 이미 있습니다", "providerProfiles": "프로필", + "providerProtocol": "API 프로토콜", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolChatCompletions": "/chat/completions (OpenAI 호환 형식)", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat (Ollama 기본 프로토콜)", + "providerProtocolResponses": "/responses (OpenAI Responses API)", + "providerReturnedBadge": "공급자 반환", "quickCommandCategory": "AI 생성", "quickPrompt": "예: CPU 사용량이 가장 높은 프로세스 찾기", "quote": "인용", @@ -352,27 +355,24 @@ "saveQuickCommand": "저장", "savedQuickCommand": "빠른 명령에 저장됨", "searchModels": "모델 검색", - "testModel": "모델 테스트", - "modelTestSucceeded": "모델 테스트 성공: {{model}}", - "modelTestFailed": "모델 테스트 실패: {{model}}", - "modelTestSucceededShort": "테스트 성공", - "modelTestFailedShort": "테스트 실패", - "modelTestUnsupported": "Codex 모델은 아직 여기서 테스트할 수 없습니다", "settings": "AI 설정", "setupAction": "설정", "setupStep1": "API 키 자격 증명 추가", "setupStep2": "모델 활성화", "setupTitle": "AI가 설정되지 않았습니다", + "showApiKey": "API 키 표시", "shownInTerminal": "터미널에 표시됨", "smartAutoExecuteMaxRisk": "스마트 자동 실행 최대 위험도", "smartAutoExecuteMaxRiskDesc": "로컬 검토 위험도가 이 수준 이하일 때 명령을 자동 실행합니다. 심각 등급은 항상 확인이 필요합니다.", "stepCommand": "명령", "stepDuration": "{{ms}}ms", "stepExitCode": "종료 코드 {{code}}", + "supportedReasoningEfforts": "지원되는 추론 강도", "targetSession": "대상", "terminalActions": "터미널 AI 작업", "terminalOutputLines": "출력 줄 수", "terminalOutputLinesDesc": "AI Agent가 명령을 실행할 때 터미널에 표시할 줄 수입니다. 0으로 설정하면 인라인 출력을 숨깁니다.", + "testModel": "모델 테스트", "thinking": "생각 중", "thoughtComplete": "생각 완료", "timeoutMs": "제한 시간(ms)", @@ -1285,7 +1285,6 @@ "uploadStarted": "파일 \"{{name}}\" 업로드 중..." }, "gpuMonitor": { - "paused": "모니터링이 일시 중지되었습니다. 새로 고쳐 재개하세요.", "cuda": "CUDA", "details": "세부 정보", "disabled": "설정에서 GPU 모니터링을 활성화하세요", @@ -1304,6 +1303,7 @@ "noMatches": "일치하는 GPU 프로세스가 없습니다", "noProcesses": "활성 GPU 연산 프로세스가 없습니다", "noSession": "SSH로 연결하여 NVIDIA GPU를 모니터링하세요", + "paused": "모니터링이 일시 중지되었습니다. 새로 고쳐 재개하세요.", "pid": "PID", "power": "전력", "search": "GPU 프로세스 검색...", @@ -1643,7 +1643,6 @@ "usernamePlaceholder": "예: root" }, "plugins": { - "probeTrust": "승인 후 선택한 SSH 세션에서 스크립트가 자동 실행됩니다. 내용을 검토하세요. 타사 스크립트는 읽기 전용을 보장하지 않습니다. 버전 업데이트 후 다시 승인해야 합니다.", "allowOnce": "이번만 허용", "approvalDescription": "{{name}}에서 선택한 세션에 대한 접근을 요청합니다.", "approvalTitle": "플러그인 작업 승인", @@ -1682,10 +1681,10 @@ "nativeTrust": "네이티브 백엔드는 운영체제 사용자 권한으로 실행됩니다. 호스트 권한과 별개로 파일, 네트워크 및 프로그램에 접근할 수 있습니다. 신뢰할 수 있는 게시자의 네이티브 코드만 활성화하세요.", "noInstalled": "설치된 플러그인이 없습니다.", "permissions": { - "remote_probe": "플러그인이 선언한 원격 스크립트 자동 실행", "filesystem_read": "선택한 세션의 텍스트 파일 읽기 요청", "native": "이 플러그인의 네이티브 실행 파일 실행", "network": "{{origin}}에 HTTPS 요청 보내기", + "remote_probe": "플러그인이 선언한 원격 스크립트 자동 실행", "session_read": "선택한 세션의 이름 및 유형 읽기", "storage": "이 플러그인의 설정 저장", "terminal_execute": "선택한 터미널에서 명령 실행 요청", @@ -1693,6 +1692,7 @@ }, "permissionsDescription": "{{name}}에서 사용할 기능을 선택하세요. 터미널 명령과 파일 읽기는 매번 승인이 필요합니다.", "permissionsTitle": "권한", + "probeTrust": "승인 후 선택한 SSH 세션에서 스크립트가 자동 실행됩니다. 내용을 검토하세요. 타사 스크립트는 읽기 전용을 보장하지 않습니다. 버전 업데이트 후 다시 승인해야 합니다.", "publisher": "게시자: {{name}}", "publisherUnverified": "게시자 이름은 패키지에 선언된 정보이며 검증되지 않았습니다.", "retry": "다시 시도", @@ -1700,6 +1700,24 @@ "reviewPackage": "플러그인 패키지 검토", "running": "플러그인 명령 실행 중…", "saveAndEnable": "저장 및 활성화", + "store": { + "empty": "스토어에 일치하는 플러그인이 없습니다.", + "installedTab": "설치됨", + "local": "로컬 플러그인", + "marketplace": "마켓플레이스", + "official": "NyaTerm 플러그인 스토어", + "publisherVerified": "인증된 게시자", + "refresh": "새로 고침", + "repository": "저장소", + "search": "플러그인 검색…", + "source": "소스 코드", + "sourceConflict": "공식 스토어로 전환하기 전에 로컬 플러그인을 제거하세요.", + "unsigned": "서명 없음", + "unsupported": "지원하지 않는 플랫폼", + "untrusted": "신뢰할 수 없는 서명 키", + "update": "업데이트 검토", + "verified": "서명 확인됨" + }, "title": "플러그인", "unavailable": "이 플러그인 패널은 비활성화되었거나 사용할 수 없습니다. 레이아웃은 유지됩니다.", "uninstall": "제거", @@ -2622,19 +2640,19 @@ "syncDebounceSeconds": "자동 업로드 지연(초)", "syncDebounceSecondsDesc": "최근 로컬 변경 후 동기화 스냅샷을 업로드하기까지 대기하는 시간입니다.", "syncEnableFirst": "먼저 클라우드 동기화를 활성화하세요.", + "syncGistCapacityHint": "Gitee gist 파일 수 한도에 도달했을 수 있습니다. 오래된 스냅샷을 정리한 뒤 다시 푸시하면 보통 복구됩니다.", "syncMasterPasswordMissingDesc": "먼저 마스터 비밀번호를 설정하세요. 설정되기 전까지 클라우드 동기화는 비활성화된 상태로 유지됩니다.", "syncMasterPasswordReadyDesc": "클라우드 동기화는 마스터 비밀번호를 사용해 원격 스냅샷을 암호화합니다.", "syncProvider": "공급자", "syncProviderConfig": "공급자 구성", "syncProviderConfigDesc": "클라우드 동기화에 사용할 원격 저장소를 구성합니다.", "syncProviderDesc": "OpenDAL이 사용할 원격 저장소 백엔드를 선택하세요.", + "syncPruneAndRetry": "원격 스냅샷 정리 후 재시도", + "syncPruneAndRetrySuccess": "원격 스냅샷을 정리하고 푸시에 성공했습니다.", "syncPullNow": "지금 다운로드", "syncPullSuccess": "스냅샷을 다운로드했습니다.", "syncPushNow": "지금 업로드", "syncPushSuccess": "스냅샷을 업로드했습니다.", - "syncPruneAndRetry": "원격 스냅샷 정리 후 재시도", - "syncPruneAndRetrySuccess": "원격 스냅샷을 정리하고 푸시에 성공했습니다.", - "syncGistCapacityHint": "Gitee gist 파일 수 한도에 도달했을 수 있습니다. 오래된 스냅샷을 정리한 뒤 다시 푸시하면 보통 복구됩니다.", "syncRecoverCurrentSuccess": "원격 동기화 메타데이터를 복구했습니다.", "syncRemoteIncompleteTitle": "불완전한 원격 동기화 데이터", "syncResolveDownloadSuccess": "원격 버전을 다운로드했습니다.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index d799dffb..1a3365e6 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -52,6 +52,7 @@ "actionName": "功能名称", "actionPrompt": "功能提示词", "activeProfile": "当前配置档", + "addProvider": "添加供应商", "agentApproveExecute": "执行", "agentAwaitingApproval": "等待确认执行…", "agentCommandExecutionMode": "命令执行策略", @@ -94,6 +95,7 @@ "backgroundAgentExecutionDesc": "命令在后台任务中运行,结果回传到当前 AI 会话。", "baseUrl": "API 地址", "baseUrlRootHint": "请填写 API 根地址,不要包含具体的聊天请求路径。", + "changeProviderIcon": "更换供应商图标", "claudeCodeAuthMode": "认证模式", "claudeCodeConfigDirectory": "配置目录", "claudeCodeConnected": "可用", @@ -145,10 +147,22 @@ "commandTargetLabel": "目标:{{target}}", "commandTargetMissing": "缺少命令目标", "commandTargetUnavailable": "目标终端不可用", + "confirmAddProvider": "确认添加", + "connectionModelCount": "{{count}} 个模型", + "connectionStatus": { + "error": "连接失败", + "idle": "尚未测试", + "success": "连接成功", + "testing": "正在测试连接…" + }, + "connectionTest": "连接测试", + "connectionTestSucceeded": "供应商连接成功", + "connectionTestUnsupported": "目前仅支持测试 OpenAI 兼容供应商", "contextLineLimit": "默认上下文行数", "copied": "已复制", "copy": "复制", "customProvider": "自定义供应商", + "customProviderOption": "自定义 / Custom", "dateEarlier": "更早", "dateLast7Days": "最近7天", "dateToday": "今天", @@ -156,9 +170,13 @@ "defaultSession": "当前会话(默认)", "deleteManualModel": "删除手动模型", "deleteModel": "删除模型", + "deleteProvider": "删除供应商", + "deleteProviderConfirm": "确定删除“{{name}}”吗?关联的模型配置也会被移除。", "deleteSession": "删除", "detect": "检测", "disabled": "AI 助手未启用", + "discardProviderConfirm": "确定放弃添加“{{name}}”吗?已填写的参数将会丢失。", + "editModelConfig": "编辑模型配置", "empty": "让 AI 帮你解释终端输出或生成命令。", "enableAutoExecution": "开启全自动", "enableFullAccess": "启用完全权限", @@ -209,6 +227,7 @@ "generate": "生成", "generateCommand": "AI 生成命令", "goToSettingsToEnable": "请前往设置启用", + "hideApiKey": "隐藏 API Key", "history": "历史", "historyCurrentTerminal": "当前终端", "historyInUse": "已在另一个终端打开", @@ -235,7 +254,6 @@ }, "manualModelAdded": "已添加模型:{{model}}", "manualModelBadge": "手动", - "providerReturnedBadge": "供应商返回", "manualModelDeleted": "已删除模型:{{model}}", "manualModelExists": "模型已存在:{{model}}", "manualModelNoProvider": "请先启用一个 API Key 配置档,再手动添加模型。", @@ -253,40 +271,13 @@ "model": "模型名称", "modelDiscoveryUnsupported": "当前协议暂不支持自动获取模型列表。", "modelList": "来自此供应商的模型", - "editModelConfig": "编辑模型配置", - "supportedReasoningEfforts": "支持的推理强度", "modelListDesc": "刷新会从 rust-genai 获取已知模型;新模型默认不启用。", - "providerList": "供应商列表", - "addProvider": "添加供应商", - "changeProviderIcon": "更换供应商图标", - "deleteProvider": "删除供应商", - "deleteProviderConfirm": "确定删除“{{name}}”吗?关联的模型配置也会被移除。", - "discardProviderConfirm": "确定放弃添加“{{name}}”吗?已填写的参数将会丢失。", - "customProviderOption": "自定义 / Custom", - "confirmAddProvider": "确认添加", - "providerEndpoint": "接口地址", - "providerEndpointRequired": "请填写接口地址", - "providerApiKeyRequired": "请填写 API 密钥", - "providerNameDuplicate": "供应商名称不能重复", - "providerProtocol": "接口协议", - "providerProtocolChatCompletions": "/chat/completions(OpenAI 兼容格式)", - "providerProtocolResponses": "/responses(OpenAI Responses API)", - "providerProtocolAnthropic": "Anthropic Messages API", - "providerProtocolGemini": "Google Gemini API", - "providerProtocolOllama": "/api/chat(Ollama 原生协议)", - "connectionTest": "连接测试", - "connectionTestUnsupported": "目前仅支持测试 OpenAI 兼容供应商", - "connectionTestSucceeded": "供应商连接成功", - "connectionModelCount": "{{count}} 个模型", - "connectionStatus": { - "idle": "尚未测试", - "testing": "正在测试连接…", - "success": "连接成功", - "error": "连接失败" - }, - "showApiKey": "显示 API Key", - "hideApiKey": "隐藏 API Key", "modelSelect": "模型", + "modelTestFailed": "模型“{{model}}”测试失败", + "modelTestFailedShort": "测试失败", + "modelTestSucceeded": "模型“{{model}}”测试成功", + "modelTestSucceededShort": "测试成功", + "modelTestUnsupported": "Codex 模型暂不支持在此测试", "models": "模型", "modelsRefreshed": "模型列表已刷新", "newChat": "新对话", @@ -314,9 +305,21 @@ "permissionObserverDesc": "允许普通读取;敏感读取需要确认;禁止写入操作。", "placeholder": "描述你的需求… 输入 @ 选择目标会话", "profileName": "名称", - "providerName": "供应商名称", + "providerApiKeyRequired": "请填写 API 密钥", + "providerEndpoint": "接口地址", + "providerEndpointRequired": "请填写接口地址", "providerKind": "供应商", + "providerList": "供应商列表", + "providerName": "供应商名称", + "providerNameDuplicate": "供应商名称不能重复", "providerProfiles": "模型配置档", + "providerProtocol": "接口协议", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolChatCompletions": "/chat/completions(OpenAI 兼容格式)", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat(Ollama 原生协议)", + "providerProtocolResponses": "/responses(OpenAI Responses API)", + "providerReturnedBadge": "供应商返回", "quickCommandCategory": "AI 生成", "quickPrompt": "例如:查 CPU 占用最高的进程", "quote": "引用", @@ -352,27 +355,24 @@ "saveQuickCommand": "保存", "savedQuickCommand": "已保存到快捷命令", "searchModels": "搜索模型", - "testModel": "测试模型", - "modelTestSucceeded": "模型“{{model}}”测试成功", - "modelTestFailed": "模型“{{model}}”测试失败", - "modelTestSucceededShort": "测试成功", - "modelTestFailedShort": "测试失败", - "modelTestUnsupported": "Codex 模型暂不支持在此测试", "settings": "AI 设置", "setupAction": "前往设置", "setupStep1": "添加 API Key 凭证(支持 OpenAI / Anthropic / DeepSeek 等)", "setupStep2": "启用至少一个可用模型", "setupTitle": "AI 助手尚未配置", + "showApiKey": "显示 API Key", "shownInTerminal": "已显示在终端", "smartAutoExecuteMaxRisk": "智能模式自动执行上限", "smartAutoExecuteMaxRiskDesc": "本地复核后的风险不高于该等级时自动执行;极高风险始终需要确认。", "stepCommand": "命令", "stepDuration": "耗时 {{ms}}ms", "stepExitCode": "退出码 {{code}}", + "supportedReasoningEfforts": "支持的推理强度", "targetSession": "目标", "terminalActions": "终端右键 AI 功能", "terminalOutputLines": "终端输出行数", "terminalOutputLinesDesc": "AI Agent 执行命令时在终端中显示的输出行数。设为 0 则禁用终端内联显示。", + "testModel": "测试模型", "thinking": "思考中", "thoughtComplete": "已思考", "timeoutMs": "请求超时时间 ms", @@ -1285,7 +1285,6 @@ "uploadStarted": "正在上传文件「{{name}}」…" }, "gpuMonitor": { - "paused": "监控已暂停,刷新可恢复。", "cuda": "CUDA", "details": "详情", "disabled": "请在设置中启用 GPU 监控", @@ -1304,6 +1303,7 @@ "noMatches": "没有匹配的 GPU 进程", "noProcesses": "暂无活跃 GPU 计算进程", "noSession": "连接 SSH 会话以监控 NVIDIA GPU", + "paused": "监控已暂停,刷新可恢复。", "pid": "PID", "power": "功耗", "search": "搜索 GPU 进程...", @@ -1643,7 +1643,6 @@ "usernamePlaceholder": "例如:root" }, "plugins": { - "probeTrust": "授权后,这些脚本会在选中的 SSH 会话中自动执行。请审核脚本内容;第三方脚本不保证只读。更新版本后需要重新授权。", "allowOnce": "仅允许本次", "approvalDescription": "{{name}} 请求访问所选会话。", "approvalTitle": "批准插件操作", @@ -1682,10 +1681,10 @@ "nativeTrust": "原生后端以当前系统用户权限运行,可独立访问文件、网络和程序,宿主权限无法限制这些操作。请仅启用可信作者提供的原生代码。", "noInstalled": "尚未安装插件。", "permissions": { - "remote_probe": "自动执行插件声明的远程脚本", "filesystem_read": "请求读取所选会话中的文本文件", "native": "运行此插件的原生程序", "network": "向 {{origin}} 发起 HTTPS 请求", + "remote_probe": "自动执行插件声明的远程脚本", "session_read": "读取所选会话的名称和类型", "storage": "保存此插件自己的设置", "terminal_execute": "请求在所选终端执行命令", @@ -1693,6 +1692,7 @@ }, "permissionsDescription": "选择 {{name}} 可使用的能力。终端命令和文件读取仍需逐次批准。", "permissionsTitle": "权限", + "probeTrust": "授权后,这些脚本会在选中的 SSH 会话中自动执行。请审核脚本内容;第三方脚本不保证只读。更新版本后需要重新授权。", "publisher": "作者:{{name}}", "publisherUnverified": "作者名称由插件包声明,尚未经过验证。", "retry": "重试", @@ -1700,6 +1700,24 @@ "reviewPackage": "检查插件包", "running": "正在运行插件命令…", "saveAndEnable": "保存并启用", + "store": { + "empty": "插件市场中没有匹配的插件。", + "installedTab": "已安装", + "local": "本地插件", + "marketplace": "插件市场", + "official": "NyaTerm 官方插件市场", + "publisherVerified": "发布者已认证", + "refresh": "刷新", + "repository": "插件仓库", + "search": "搜索插件…", + "source": "源码", + "sourceConflict": "切换到官方插件市场前,请先卸载本地插件。", + "unsigned": "未签名", + "unsupported": "不支持当前平台", + "untrusted": "签名密钥不受信任", + "update": "审核更新", + "verified": "签名已验证" + }, "title": "插件", "unavailable": "此插件面板已停用或暂不可用,布局位置已保留。", "uninstall": "卸载", @@ -2622,19 +2640,19 @@ "syncDebounceSeconds": "自动推送防抖(秒)", "syncDebounceSecondsDesc": "距离最近一次本地变更过去这么久后,才会自动上传同步快照。", "syncEnableFirst": "请先启用云同步再执行该操作。", + "syncGistCapacityHint": "Gitee 代码片段可能已达文件数上限。清理旧快照后重新推送通常即可恢复。", "syncMasterPasswordMissingDesc": "请先设置主密码。未配置主密码前,云同步保持禁用。", "syncMasterPasswordReadyDesc": "云同步会使用你的主密码加密远端快照。", "syncProvider": "提供方", "syncProviderConfig": "存储提供方配置", "syncProviderConfigDesc": "配置云同步使用的远程存储提供方与命名空间。", "syncProviderDesc": "选择 OpenDAL 要连接的远程存储后端。", + "syncPruneAndRetry": "清理远端旧快照并重试", + "syncPruneAndRetrySuccess": "已清理远端旧快照并推送成功。", "syncPullNow": "立即拉取", "syncPullSuccess": "同步快照已下载。", "syncPushNow": "立即推送", "syncPushSuccess": "同步快照已上传。", - "syncPruneAndRetry": "清理远端旧快照并重试", - "syncPruneAndRetrySuccess": "已清理远端旧快照并推送成功。", - "syncGistCapacityHint": "Gitee 代码片段可能已达文件数上限。清理旧快照后重新推送通常即可恢复。", "syncRecoverCurrentSuccess": "远端同步元数据已恢复。", "syncRemoteIncompleteTitle": "远端同步数据不完整", "syncResolveDownloadSuccess": "远程版本已成功下载。", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index bc65d664..cde56205 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -52,6 +52,7 @@ "actionName": "功能名稱", "actionPrompt": "功能提示詞", "activeProfile": "目前設定檔", + "addProvider": "新增供應商", "agentApproveExecute": "執行", "agentAwaitingApproval": "等待確認執行…", "agentCommandExecutionMode": "命令執行策略", @@ -94,6 +95,7 @@ "backgroundAgentExecutionDesc": "命令在背景任務中執行,結果回傳到目前 AI 工作階段。", "baseUrl": "API 網址", "baseUrlRootHint": "請填寫 API 根網址,不要包含具體的聊天請求路徑。", + "changeProviderIcon": "更換供應商圖示", "claudeCodeAuthMode": "認證模式", "claudeCodeConfigDirectory": "設定目錄", "claudeCodeConnected": "可用", @@ -145,10 +147,22 @@ "commandTargetLabel": "目標:{{target}}", "commandTargetMissing": "缺少命令目標", "commandTargetUnavailable": "目標終端不可用", + "confirmAddProvider": "確認新增", + "connectionModelCount": "{{count}} 個模型", + "connectionStatus": { + "error": "連線失敗", + "idle": "尚未測試", + "success": "連線成功", + "testing": "正在測試連線…" + }, + "connectionTest": "連線測試", + "connectionTestSucceeded": "供應商連線成功", + "connectionTestUnsupported": "目前僅支援測試 OpenAI 相容供應商", "contextLineLimit": "預設上下文行數", "copied": "已複製", "copy": "複製", "customProvider": "自訂供應商", + "customProviderOption": "自訂 / Custom", "dateEarlier": "更早", "dateLast7Days": "最近 7 天", "dateToday": "今天", @@ -156,9 +170,13 @@ "defaultSession": "目前工作階段(預設)", "deleteManualModel": "刪除手動模型", "deleteModel": "刪除模型", + "deleteProvider": "刪除供應商", + "deleteProviderConfirm": "確定刪除「{{name}}」嗎?關聯的模型設定也會一併移除。", "deleteSession": "刪除", "detect": "偵測", "disabled": "AI 助手未啟用", + "discardProviderConfirm": "確定放棄新增「{{name}}」嗎?已填寫的參數將會遺失。", + "editModelConfig": "編輯模型設定", "empty": "讓 AI 幫你解釋終端輸出或產生命令。", "enableAutoExecution": "開啟全自動", "enableFullAccess": "啟用完全權限", @@ -209,6 +227,7 @@ "generate": "產生", "generateCommand": "AI 產生命令", "goToSettingsToEnable": "請前往設定啟用", + "hideApiKey": "隱藏 API Key", "history": "歷史", "historyCurrentTerminal": "目前終端", "historyInUse": "已在另一個終端開啟", @@ -235,7 +254,6 @@ }, "manualModelAdded": "已新增模型:{{model}}", "manualModelBadge": "手動", - "providerReturnedBadge": "供應商回傳", "manualModelDeleted": "已刪除模型:{{model}}", "manualModelExists": "模型已存在:{{model}}", "manualModelNoProvider": "請先啟用一個 API Key 設定檔,再手動新增模型。", @@ -253,40 +271,13 @@ "model": "模型名稱", "modelDiscoveryUnsupported": "目前協定暫不支援自動取得模型清單。", "modelList": "來自此供應商的模型", - "editModelConfig": "編輯模型設定", - "supportedReasoningEfforts": "支援的推理強度", "modelListDesc": "重新整理會從 rust-genai 取得已知模型;新模型預設不啟用。", - "providerList": "供應商清單", - "addProvider": "新增供應商", - "changeProviderIcon": "更換供應商圖示", - "deleteProvider": "刪除供應商", - "deleteProviderConfirm": "確定刪除「{{name}}」嗎?關聯的模型設定也會一併移除。", - "discardProviderConfirm": "確定放棄新增「{{name}}」嗎?已填寫的參數將會遺失。", - "customProviderOption": "自訂 / Custom", - "confirmAddProvider": "確認新增", - "providerEndpoint": "介面地址", - "providerEndpointRequired": "請填寫介面地址", - "providerApiKeyRequired": "請填寫 API 金鑰", - "providerNameDuplicate": "供應商名稱不可重複", - "providerProtocol": "介面協定", - "providerProtocolChatCompletions": "/chat/completions(OpenAI 相容格式)", - "providerProtocolResponses": "/responses(OpenAI Responses API)", - "providerProtocolAnthropic": "Anthropic Messages API", - "providerProtocolGemini": "Google Gemini API", - "providerProtocolOllama": "/api/chat(Ollama 原生協定)", - "connectionTest": "連線測試", - "connectionTestUnsupported": "目前僅支援測試 OpenAI 相容供應商", - "connectionTestSucceeded": "供應商連線成功", - "connectionModelCount": "{{count}} 個模型", - "connectionStatus": { - "idle": "尚未測試", - "testing": "正在測試連線…", - "success": "連線成功", - "error": "連線失敗" - }, - "showApiKey": "顯示 API Key", - "hideApiKey": "隱藏 API Key", "modelSelect": "模型", + "modelTestFailed": "模型「{{model}}」測試失敗", + "modelTestFailedShort": "測試失敗", + "modelTestSucceeded": "模型「{{model}}」測試成功", + "modelTestSucceededShort": "測試成功", + "modelTestUnsupported": "Codex 模型暫不支援在此測試", "models": "模型", "modelsRefreshed": "模型清單已重新整理", "newChat": "新對話", @@ -314,9 +305,21 @@ "permissionObserverDesc": "允許一般讀取;敏感讀取需要確認;禁止寫入操作。", "placeholder": "描述你的需求… 輸入 @ 選擇目標工作階段", "profileName": "名稱", - "providerName": "供應商名稱", + "providerApiKeyRequired": "請填寫 API 金鑰", + "providerEndpoint": "介面地址", + "providerEndpointRequired": "請填寫介面地址", "providerKind": "供應商", + "providerList": "供應商清單", + "providerName": "供應商名稱", + "providerNameDuplicate": "供應商名稱不可重複", "providerProfiles": "模型設定檔", + "providerProtocol": "介面協定", + "providerProtocolAnthropic": "Anthropic Messages API", + "providerProtocolChatCompletions": "/chat/completions(OpenAI 相容格式)", + "providerProtocolGemini": "Google Gemini API", + "providerProtocolOllama": "/api/chat(Ollama 原生協定)", + "providerProtocolResponses": "/responses(OpenAI Responses API)", + "providerReturnedBadge": "供應商回傳", "quickCommandCategory": "AI 產生", "quickPrompt": "例如:查 CPU 佔用最高的處理程序", "quote": "引用", @@ -352,27 +355,24 @@ "saveQuickCommand": "儲存", "savedQuickCommand": "已儲存到快捷命令", "searchModels": "搜尋模型", - "testModel": "測試模型", - "modelTestSucceeded": "模型「{{model}}」測試成功", - "modelTestFailed": "模型「{{model}}」測試失敗", - "modelTestSucceededShort": "測試成功", - "modelTestFailedShort": "測試失敗", - "modelTestUnsupported": "Codex 模型暫不支援在此測試", "settings": "AI 設定", "setupAction": "前往設定", "setupStep1": "新增 API Key 認證資訊(支援 OpenAI / Anthropic / DeepSeek 等)", "setupStep2": "啟用至少一個可用模型", "setupTitle": "AI 助手尚未設定", + "showApiKey": "顯示 API Key", "shownInTerminal": "已顯示在終端", "smartAutoExecuteMaxRisk": "智慧模式自動執行上限", "smartAutoExecuteMaxRiskDesc": "本機複核後的風險不高於該等級時自動執行;極高風險始終需要確認。", "stepCommand": "命令", "stepDuration": "耗時 {{ms}}ms", "stepExitCode": "退出碼 {{code}}", + "supportedReasoningEfforts": "支援的推理強度", "targetSession": "目標", "terminalActions": "終端右鍵 AI 功能", "terminalOutputLines": "終端輸出行數", "terminalOutputLinesDesc": "AI Agent 執行命令時在終端中顯示的輸出行數。設為 0 則停用終端內聯顯示。", + "testModel": "測試模型", "thinking": "思考中", "thoughtComplete": "已思考", "timeoutMs": "請求逾時時間 ms", @@ -1285,7 +1285,6 @@ "uploadStarted": "正在上傳檔案「{{name}}」…" }, "gpuMonitor": { - "paused": "監控已暫停,重新整理可恢復。", "cuda": "CUDA", "details": "詳情", "disabled": "請在設定中啟用 GPU 監控", @@ -1304,6 +1303,7 @@ "noMatches": "沒有符合的 GPU 處理程序", "noProcesses": "暫無使用中 GPU 計算處理程序", "noSession": "連線 SSH 工作階段以監控 NVIDIA GPU", + "paused": "監控已暫停,重新整理可恢復。", "pid": "PID", "power": "功耗", "search": "搜尋 GPU 處理程序...", @@ -1643,7 +1643,6 @@ "usernamePlaceholder": "例如:root" }, "plugins": { - "probeTrust": "授權後,這些指令碼會在選取的 SSH 工作階段中自動執行。請審核內容;第三方指令碼不保證唯讀。更新版本後需要重新授權。", "allowOnce": "僅允許本次", "approvalDescription": "{{name}} 要求存取所選工作階段。", "approvalTitle": "核准外掛操作", @@ -1682,10 +1681,10 @@ "nativeTrust": "原生後端以目前系統使用者權限執行,可獨立存取檔案、網路及程式,宿主權限無法限制這些操作。請僅啟用可信作者提供的原生程式碼。", "noInstalled": "尚未安裝外掛。", "permissions": { - "remote_probe": "自動執行外掛宣告的遠端指令碼", "filesystem_read": "要求讀取所選工作階段中的文字檔案", "native": "執行此外掛的原生程式", "network": "向 {{origin}} 發出 HTTPS 要求", + "remote_probe": "自動執行外掛宣告的遠端指令碼", "session_read": "讀取所選工作階段的名稱和類型", "storage": "儲存此外掛自己的設定", "terminal_execute": "要求在所選終端執行命令", @@ -1693,6 +1692,7 @@ }, "permissionsDescription": "選擇 {{name}} 可使用的能力。終端命令和檔案讀取仍需逐次核准。", "permissionsTitle": "權限", + "probeTrust": "授權後,這些指令碼會在選取的 SSH 工作階段中自動執行。請審核內容;第三方指令碼不保證唯讀。更新版本後需要重新授權。", "publisher": "作者:{{name}}", "publisherUnverified": "作者名稱由外掛套件宣告,尚未經過驗證。", "retry": "重試", @@ -1700,6 +1700,24 @@ "reviewPackage": "檢查外掛套件", "running": "正在執行外掛命令…", "saveAndEnable": "儲存並啟用", + "store": { + "empty": "外掛市場中沒有符合的外掛。", + "installedTab": "已安裝", + "local": "本機外掛", + "marketplace": "外掛市場", + "official": "NyaTerm 官方外掛市場", + "publisherVerified": "發行者已認證", + "refresh": "重新整理", + "repository": "外掛儲存庫", + "search": "搜尋外掛…", + "source": "原始碼", + "sourceConflict": "切換到官方外掛市場前,請先解除安裝本機外掛。", + "unsigned": "未簽章", + "unsupported": "不支援目前平台", + "untrusted": "簽章金鑰不受信任", + "update": "審核更新", + "verified": "簽章已驗證" + }, "title": "外掛", "unavailable": "此外掛面板已停用或暫時無法使用,版面位置已保留。", "uninstall": "解除安裝", @@ -2617,19 +2635,19 @@ "syncDebounceSeconds": "自動推送防抖(秒)", "syncDebounceSecondsDesc": "距離最近一次本機變更過去這麼久後,才會自動上傳雲端同步快照。", "syncEnableFirst": "請先啟用雲端同步再執行該操作。", + "syncGistCapacityHint": "Gitee 程式碼片段可能已達檔案數上限。清理舊快照後重新推送通常即可恢復。", "syncMasterPasswordMissingDesc": "請先設定主密碼。未設定主密碼前,雲端同步保持停用。", "syncMasterPasswordReadyDesc": "雲端同步會使用你的主密碼加密遠端快照。", "syncProvider": "提供方", "syncProviderConfig": "儲存提供方設定", "syncProviderConfigDesc": "設定雲端同步使用的遠端儲存提供方與名稱空間。", "syncProviderDesc": "選擇 OpenDAL 要連線的遠端儲存後端。", + "syncPruneAndRetry": "清理遠端舊快照並重試", + "syncPruneAndRetrySuccess": "已清理遠端舊快照並推送成功。", "syncPullNow": "立即拉取", "syncPullSuccess": "雲端同步快照已下載。", "syncPushNow": "立即推送", "syncPushSuccess": "雲端同步快照已上傳。", - "syncPruneAndRetry": "清理遠端舊快照並重試", - "syncPruneAndRetrySuccess": "已清理遠端舊快照並推送成功。", - "syncGistCapacityHint": "Gitee 程式碼片段可能已達檔案數上限。清理舊快照後重新推送通常即可恢復。", "syncRecoverCurrentSuccess": "遠端同步中繼資料已復原。", "syncRemoteIncompleteTitle": "遠端同步資料不完整", "syncResolveDownloadSuccess": "遠端版本已成功下載。", diff --git a/src/lib/plugins.ts b/src/lib/plugins.ts index d70bb684..e5737288 100644 --- a/src/lib/plugins.ts +++ b/src/lib/plugins.ts @@ -1,6 +1,8 @@ import { invoke } from "@/lib/invoke"; import type { InstalledPlugin, + MarketplaceCatalog, + MarketplacePreview, PluginDiagnosticsSnapshot, PluginMonitorSubscription, PluginPackagePreview, @@ -8,6 +10,13 @@ import type { } from "@/types/plugins"; export const pluginApi = { + marketplace: () => invoke("get_plugin_marketplace"), + inspectMarketplace: (pluginId: string, version: string, expectedSha256: string) => + invoke("inspect_marketplace_plugin", { pluginId, version, expectedSha256 }), + installMarketplace: (token: string) => + invoke("install_marketplace_plugin", { token }), + cancelMarketplaceReview: (token: string) => + invoke("cancel_marketplace_plugin_review", { token }), probeScripts: (pluginId: string, expectedVersion: string) => invoke>("get_plugin_probe_scripts", { pluginId, expectedVersion }), subscribeMonitor: (token: string, monitorId: string, intervalSeconds: number) => diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 9733e926..d9a3e346 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -53,7 +53,15 @@ export interface InstalledPlugin { activeVersion: string; enabled: boolean; grantedPermissions: PluginPermission[]; - versions: Record; + versions: Record< + string, + { + manifest: PluginManifest; + digest: string; + provenance?: PluginProvenance; + signature?: PluginSignatureStatus; + } + >; } export interface PluginPackagePreview { @@ -61,6 +69,62 @@ export interface PluginPackagePreview { digest: string; expandedBytes: number; probeScripts?: Record; + signature: PluginSignatureStatus; +} + +export type PluginSignatureStatus = + | { status: "unsigned" } + | { status: "untrusted" | "verified"; keyId: string }; +export type PluginProvenance = + | { source: "local" } + | { + source: "marketplace"; + repositoryId: string; + publisher: string; + signingKeyId: string; + packageSha256: string; + }; +export interface MarketplaceArtifact { + target: string; + url: string; + sha256: string; + size: number; + signingKeyId: string; +} +export interface MarketplaceVersion { + version: string; + releasedAt: string; + releaseNotes: string; + permissions: string[]; + artifacts: MarketplaceArtifact[]; +} +export interface MarketplacePlugin { + id: string; + name: string; + description: string; + publisher: string; + verified: boolean; + tags: string[]; + source: string; + homepage: string; + license: string; + permissions: string[]; + latestVersion: string; + versions: MarketplaceVersion[]; +} +export interface MarketplaceCatalog { + target: string; + catalog: { + catalogVersion: number; + repository: { id: string; name: string }; + generatedAt: string; + plugins: MarketplacePlugin[]; + }; +} +export interface MarketplacePreview { + token: string; + package: PluginPackagePreview; + repositoryId: string; } export interface PluginMonitorSnapshot {