feat(plugin): add new plugin creation, packaging, and example commands; enhance Vite config and documentation for plugin development

This commit is contained in:
Kang committed 2026-10-03 16:09:58 +08:00
1 parent 7201039472
commit bb0c24d5e5
84 files changed
+9683 -19

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Plugin development and diagnostics
This stage completes four deliverables on top of the local plugin host:
Implemented and verified. See [the development guide](plugin-development.md),
[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md) and
[validation report](plugin-system-validation.md#developer-tooling-and-diagnostics-validation).
- `pnpm plugin:create <id> <directory> --template ui|rust` creates a new, buildable
project without overwriting existing content. Each template includes a manifest,
working entrypoint, SDK integration, build/package instructions and README.
- A standalone Rust native SDK handles identity/API handshake, JSONL and framed
transport, concurrent requests, scoped reverse host calls, structured errors,
timeouts, cooperative cancellation and logging. The existing native counter
and generated Rust projects use the SDK.
- The Plugins panel displays idle/starting/running/stopped/error states for native
backends and distinguishes UI-only and disabled plugins. Users can stop a backend
and see its next invocation activate a fresh process.
- Users can inspect, refresh, copy and clear bounded in-memory logs. Startup,
protocol, crash and invocation errors are retained even during the handshake.
Old processes cannot overwrite the state of a newer activation. Logs are
treated as untrusted text, redact host-known secrets/scope tokens, and are not
persisted or added to backup/cloud sync.
Verification must exercise generated projects through build/package validation,
the SDK through the real host runtime in both transports (including reverse RPC,
errors, cancellation and crash handling), status/log transitions and retention,
and the frontend diagnostic controls. Type checking, relevant lint and frontend
build remain final gates. Marketplace, connection provider APIs and browser data
stream subscriptions remain separate stages.
+87
View File
@@ -0,0 +1,87 @@
# 插件开发与排错
插件安装、授权、运行状态和日志都在 **插件面板** 中操作。
## 创建项目
在 NyaTerm 仓库根目录执行:
```sh
# 纯界面插件:查看选中终端的会话信息
pnpm plugin:create example.session temp/plugins/my-session --template ui
pnpm plugin:pack temp/plugins/my-session temp/plugins/my-session.nyap
# Rust 插件:界面调用后台,也提供原生命令
pnpm plugin:create example.hello temp/plugins/my-hello --template rust
node temp/plugins/my-hello/build.mjs
pnpm plugin:pack temp/plugins/my-hello temp/plugins/my-hello.nyap
```
创建器拒绝覆盖已有目录。项目包含 manifest、可用入口、SDK 和 README;
Rust 模板还包含编译脚本,编译后自动填入当前平台的可执行文件路径。
需要 Node 和 Rust 1.94 或更新版本。Rust SDK 使用本地路径依赖,移动项目时
应一起提供 SDK 或调整 Cargo.toml 中的路径。
将生成的 `.nyap` 安装到插件面板,检查权限并启用。UI 模板需先选中已连接终端;
Rust 模板可以打开界面点击运行,也可以直接执行插件卡片上的原生命令。
## 编写 Rust 后台
入口实现 `Plugin`,由 `Server::from_env()` 启动服务。SDK 负责握手、传输、
并发、作用域、超时和取消,无需手写 JSON-RPC:
```rust
#[async_trait]
impl Plugin for MyPlugin {
async fn call(&self, context: Context, method: &str, input: Value) -> Result<Value> {
match method {
"ui/session" => {
let session = context.host().session().await?;
context.log("info", "Session inspected").await?;
Ok(session)
}
_ => Err(RpcError::method_not_found(method)),
}
}
}
```
以上会话读取需在 manifest 中声明并获授 `session.read`;原生执行需 `native`。
UI 调用 `NyaTerm.backend("ui/session")`,contributions.commands 中的原生命令
则使用 manifest 声明的方法。不要向 stdout 打印日志,它专用于协议;使用
`Context::log` 或 stderr。不要记录口令、令牌、终端输入输出等敏感内容。
默认采用 JSONL。将 `backend.transport` 改为 `stdio-framed` 后,SDK 根据宿主
环境自动切换协议,支持二进制帧;界面流式订阅尚未提供。完整 API 和取消约定
见 [Rust SDK](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md)。
## 查看状态和日志
原生后台按需启动并复用进程。卡片显示未启动、启动中、运行中、已停止或运行异常;
禁用插件和仅界面插件有独立标识。RPC 方法返回错误时,只记录调用失败,后台仍可
继续运行;进程退出、握手或协议失败则显示运行异常。
点击 **查看日志** 可刷新、复制和清空。日志包括时间、版本、级别、来源和消息。
日志弹窗每秒刷新,卡片状态每 2.5 秒刷新;关闭插件管理面板后停止轮询。
每个插件最多保留 100 条、64 KiB 消息与来源/版本文本,单条消息最多 2 KiB。
stderr 每次启动最多采集约 64 KiB,超过后继续排空而不保留。
日志只在内存中保留,退出应用即清除,不进入备份或云同步。
宿主会遮蔽常见凭据和作用域令牌,但插件作者仍需自行避免写入敏感内容。
**停止后台** 保留启用状态和权限,下次调用会启动新进程,进程内计数等状态会重置。
插件有进行中的请求时会提示忙碌;先结束或关闭该操作,再停止后台。
禁用、更新、切换版本、锁屏和退出应用也会停止后台。旧进程的延迟消息不会覆盖
新启动进程的状态。更新插件必须提升版本号,同版本不同内容不能重新安装。
## 本地验证
```sh
node --test scripts/create-plugin.node-test.mjs
cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture
pnpm exec vitest run src/components/plugins/PluginDiagnostics.test.tsx --maxWorkers=4
```
`native-counter` 示例已使用 SDK,并升到 1.0.1:执行两次可看到计数递增和日志;
停止后台后再执行,计数从 1 开始。新版安装包为 `temp/plugins/native-counter.nyap`。
模板编译和打包已在 Windows 验证;其他平台需构建对应二进制并填写 executables。
+22
View File
@@ -0,0 +1,22 @@
# NyaTerm plugin system implementation
The requested plugin system builds on the preceding architecture review. The implementation must provide a usable extension host, local package installation, lifecycle management, permissions, isolated UI, native runtime, documentation and an end-to-end example.
Implemented as the local tool/panel extension host v1. See the [plugin guide](plugins.md)
and [validation report](plugin-system-validation.md) for the supported APIs, evidence
and remaining desktop release QA limits.
## Completion requirements
- A versioned, validated manifest declares panels, commands, menu locations, permissions and optional platform-specific native backends.
- `.nyap` packages can be inspected before installation, safely extracted with strict size/path/checksum validation, installed as immutable versions, enabled/disabled, updated, rolled back and uninstalled.
- Permissions are granted explicitly, enforced in Rust, and checked together with live session scope and application lock state. Native execution is presented as trusted code with OS user permissions.
- Plugin panels integrate with the existing activity bar and panel layouts; plugin commands are discoverable and available in declared context menus.
- Third-party HTML/JS runs in a sandboxed iframe with a restricted CSP and a bridge bound to its own iframe, plugin identity and backend-issued scope token. It cannot invoke arbitrary Tauri commands.
- The host API supports scoped session metadata, recent terminal output, approved command execution, scoped file reads, namespaced settings and declared network origins. Revocation, session closure and window teardown invalidate access.
- Optional sidecars use lazy persistent processes, a versioned initialization handshake, bounded JSON-RPC/binary frames, timeouts, crash cleanup and stop-on-disable/update behavior.
- Updates and removal do not race active requests; plugin state and permissions survive restarts. Plugin layouts survive temporary unavailability.
- A JavaScript SDK, packager, working example plugin and developer documentation cover the installation and authoring workflow.
- Meaningful backend and frontend tests, type checking, relevant linting and a build verify the implementation. A runtime smoke check verifies installation and UI/bridge integration where the environment permits.
New connection protocols and replacing built-in SSH/SFTP/RDP/VNC are subsequent provider API work described by the review, rather than requirements of this tool/panel extension host. A remote marketplace is also a separate service. Local packages and the extension runtime must be complete independently.
+85
View File
@@ -0,0 +1,85 @@
# Plugin v1 validation
Developer tooling and diagnostics were subsequently completed; see
[the development guide](plugin-development.md) and the updated verification below.
Validated on Windows on 2026-10-03. Scope follows
[the implementation plan](plugin-system-plan.md).
| Requirement | Implementation and evidence |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Versioned manifest and contributions | Independent runtime validates API/engine versions, paths, permissions, panels and command/menu declarations. Frontend contribution tests verify enabled-version discovery and namespaced IDs. |
| Local package lifecycle | Package/registry tests cover traversal, case collisions, complete checksums, modified content, inspection digest, immutable versions, persisted grants, permission revocation, update, rollback and publisher continuity. |
| Permissions and scope | Rust gateway allowlists capabilities and combines grants with plugin version, window ownership, connected session and lock checks. Approved command/file operations revalidate scope before execution. Permission review is bound to the active version to reject stale dialogs after an update. |
| Isolated UI | Bridge tests cover unrelated/spoofed frames, opaque origin, fixed scope token, arbitrary native command attempts, duplicate IDs, oversized messages and disposal. IPC tests cover top-frame initialization and Tauri binary/channel/fallback serialization. |
| Workspace integration | Unified Plugins management panel, activity panels, floating/multiple panel layouts and terminal/connection menu contributions integrate with existing workspace components. Existing workspace and context-menu tests pass. |
| Native runtime | Real child-process tests exercise handshake identity, persistent reuse, reverse RPC, timeout recovery and crash cleanup. Framed transport tests verify binary round trips and size limits. Activation cancellation and application exit/lock cleanup are implemented. |
| Developer workflow | UI and native examples both build/package. The extracted native package initializes and returns counts 1 then 2 from the same executable process. UI SDK round-trip tests and browser smoke verify the authoring path. |
| Documentation | [Plugin guide](plugins.md) specifies installation, trust, package lifecycle, storage, manifest, UI API, native protocol, limitations and maintenance. |
Checks completed:
- `pnpm exec vitest run --maxWorkers=4`: **152 files / 964 tests passed**.
Default high parallelism exceeded the existing AI settings test's five-second
timeout; limiting workers passed the entire suite. Temporary reference projects
under `temp/` are now excluded from test discovery.
- `pnpm build`: frontend type checking, MCP sidecar build and production Vite build
passed. Existing Browserslist age and large-bundle notices remain.
- `pnpm lint`: passed; one existing dependency warning in `CommandSuggestions.tsx`
remains. Plugin frontend files have no lint diagnostics.
- Runtime Cargo tests: **9 unit tests and 2 real-process tests passed**.
- Runtime `cargo clippy --all-targets -- -D warnings`: passed.
- Tauri `cargo check --lib` and `cargo clippy --lib`: passed. Existing project-wide
Clippy warnings remain; plugin-module diagnostics were checked separately.
- Tauri plugin tests: **2 passed**, for atomic lifecycle admission and rendered IPC
script templates. The Windows library-test binary failed in the loader before
running any tests; a temporary copy with a Common Controls v6 activation manifest
ran both successfully. Production application files were not altered for this
workaround.
- Real Chrome smoke using the actual bridge, SDK and Session Toolbox files:
panel loading, selected-session request, theme propagation, opaque message origin,
fixed-scope routing, parent DOM isolation, blocked direct network and absent
child-frame IPC transport all passed. Native host calls were stubbed in this
browser check.
This validation does not include a manually operated packaged Tauri desktop build
or macOS/Linux WebView testing. The browser smoke uses HTTP asset serving with the
equivalent sandbox/CSP and mocked native commands; it does not exercise Tauri's
custom resource protocol or real terminal execution dialogs. Verify those in
desktop release QA. Native plugins remain trusted OS processes; the current
format has no cryptographic publisher signatures or marketplace.
## Developer tooling and diagnostics validation
Completed on Windows, 2026-10-03:
- Project creation: UI and Rust starters generated successfully. Three Node tests
cover external paths (including spaces), valid SDK references, invalid arguments
and refusal to overwrite existing source.
- Generated UI/Rust packages pass the real package validator. The Rust starter
builds in release mode. Extracted native-counter **1.0.1** and the generated
Rust package both complete their SDK handshake, return counts **1, 2** from a
persistent process, and exit cleanly on stdin EOF.
- Rust SDK: two wire tests cover bounded frames, binary round trips, clean EOF and
truncated input. Runtime: **10 unit tests + 5 process integration tests** pass
with `--features sdk-fixture`. SDK process tests cover JSONL/framed transport,
concurrent and reverse RPC, binary data, structured logs, request cancellation,
cancellation of timed-out reverse host calls, method errors, panic recovery,
crashes, and stderr collected during a failing initialization handshake.
- Diagnostic storage tests cover retention limits, UTF-8 truncation, credential
redaction, stop/restart generations, clearing and removal. The panel distinguishes
UI-only/disabled plugins and backend states; three frontend tests cover stopping,
refresh/copy/clear, untrusted log text and stale responses after locking.
- Frontend full suite: **153 files / 967 tests pass**. `pnpm build` and `pnpm lint`
pass; the existing CommandSuggestions hook warning remains.
- SDK/runtime Clippy with `--all-targets -- -D warnings` passes. Tauri library
Clippy/check passes; there are no plugin-module diagnostics, while existing
project-wide warnings remain.
- `pnpm i18n:check` still reports existing ordering issues in the AI text sections
of all four locales. Plugin sections were formatted independently to preserve
unrelated content. All four locales contain the same diagnostic keys.
The user reported successful desktop testing of the earlier native-counter
package. The new diagnostics controls have automated frontend coverage; the
updated 1.0.1 package is ready for desktop smoke testing. This does not claim
macOS/Linux validation or a full manual desktop lifecycle test.
+309
View File
@@ -0,0 +1,309 @@
# NyaTerm plugins v1
NyaTerm supports locally installed tool and panel extensions. A plugin can add
sandboxed HTML panels, terminal/connection context-menu commands, and an optional
persistent native backend. Built-in connection protocols remain managed by
NyaTerm. Connection/filesystem providers and an online marketplace are future APIs.
## Install and use
1. Open the **Plugins** activity panel, choose **Install local package**, and select a `.nyap` file.
2. Review the plugin identity, publisher label and declared permissions. Installation
validates the package and leaves it disabled.
3. Choose **Enable**, select the permissions to grant, and save. A native backend
requires explicit `native` approval.
4. Open its panel or command from the Plugins activity item. Panels also appear in
the activity bar. Declared menu commands appear in terminal/connection menus.
The terminal selected when a panel opens defines its session scope. Selecting
another terminal recreates that scope. A terminal-menu command is scoped to that
terminal; a saved-connection-menu command has no live terminal scope. It cannot
silently access an unrelated active terminal. Opening a panel from a context menu
keeps that explicit session selection until another open intent replaces it.
Command execution and file reading each require a separate approval dialog, even
after granting their manifest permissions. Closing the panel or command dialog,
closing its session/window, locking NyaTerm, or changing plugin activation revokes
the associated scope and cancels host requests. Cancellation cannot undo a command
that has already run on the terminal.
Install a newer package with the same ID to update. Versions are immutable: the
same version cannot be replaced with different contents. Updates and switching
the version selector leave the selected version disabled for review. The old
versions remain available for rollback. Updates, grant changes and uninstall fail
while a plugin request is active; finish or close it, then retry. Disable stops the
native backend. Native processes are also stopped on lock and application exit.
## Trust and storage
UI panels run in an iframe with `sandbox="allow-scripts"`, an opaque origin and
a restrictive response CSP. Scripts, styles, images and fonts are local package
assets; direct network access, child frames, form submissions and popups are
blocked. The bridge accepts messages only from its own frame and binds requests
to a Rust-issued plugin/version/window/session scope. Plugins do not receive an
arbitrary Tauri command gateway. On Windows, where Wry injects initialization
scripts into subframes, the native IPC transport is explicitly initialized only
in the top-level document.
**Native plugins are trusted executables running with your OS user permissions.**
Host permission checks do not sandbox their filesystem, network or subprocess
access. Only enable native packages from a source you trust. SHA-256 checksums
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.
Plugin files live in the active runtime's `plugins/` directory (normally
`~/.nyaterm/plugins/`):
```text
plugins/
registry.json
vendor.plugin/versions/1.0.0/...
data/vendor.plugin/settings.json
```
`storage` holds plaintext, nonsecret JSON preferences, separately for each ID.
Do not store passwords, tokens, terminal commands or terminal output there.
Uninstall removes plugin code and registration; preferences are retained for a
later reinstall. Plugin packages, grants and preferences are excluded from the
existing portable snapshot/backup/cloud-sync payloads.
## Build the UI example
For project generation, Rust SDK APIs, status and logs, see the
[development guide](plugin-development.md) and
[native SDK reference](../plugins/sdk/rust/nyaterm-plugin-sdk/README.md).
From the NyaTerm repository (Node/pnpm and Rust required):
```sh
pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap
```
Install the generated file, enable it with the permissions you want to try, select
a live terminal and open **Session Toolbox**. Read output, request a command or
request a UTF-8 file read. The latter two actions show the native host approval UI.
Terminal execution uses the existing execution profile and shell capture engine;
sessions that disable execution retain that restriction.
The packager includes `ui/nyaterm-sdk.js` automatically. It is reserved and cannot
be overwritten with different bytes. Only `manifest.json`, `ui/`, `assets/` and
`bin/` are collected, so source code/build artifacts outside these directories do
not enter the archive. UI entry assets must be at most 4 MiB each.
## Manifest
```json
{
"contributions": {
"panels": [
{ "id": "overview", "title": "Overview", "entry": "ui/index.html" }
],
"commands": [
{
"id": "open-overview",
"title": "Open Overview",
"panel": "overview",
"menus": ["terminal", "connection"]
}
]
},
"description": "Tools for a selected terminal",
"engine": ">=1.2.12, <2.0.0",
"id": "example.tools",
"manifestVersion": 1,
"name": "Example Tools",
"permissions": ["session.read", "terminal.read", "storage"],
"publisher": "Example",
"version": "1.0.0"
}
```
Unknown fields are rejected. IDs use lowercase ASCII letters, digits, `.`, `_`
and `-`, start with a letter/digit and are at most 128 bytes; `..` is prohibited.
Plugin IDs also contain a dot to separate their namespace.
Versions use canonical SemVer; `engine` uses Rust semver requirements, including
comma-separated comparators. Panel entries are `ui/*.html`. A command references
exactly one panel or a declared `command/*` native method. Panel and command IDs
are unique across the contribution set. Optional menus are `terminal` and
`connection`. Panels use persisted `plugin:<pluginId>:<panelId>` activity IDs and
retain their layout when temporarily disabled or unavailable.
Limits: 16 panels, 64 commands, 24 permissions including at most 8 exact HTTPS
origins. Package limits: 128 MiB compressed, 256 MiB expanded, 64 MiB per file and
1024 archive entries. Paths must be portable relative paths; traversal, symlinks,
special files, Windows aliases and case collisions are rejected. `checksums.json`
maps every other archive filename, including the manifest, to its SHA-256 hash.
Use the packager to produce this file rather than maintaining hashes by hand.
## UI SDK and host API
Load classic scripts from local files. Inline styles are allowed; use a local
bundled JS file rather than a CDN or dynamic imports requiring cross-origin access.
```html
<script src="nyaterm-sdk.js"></script>
<script src="app.js"></script>
```
```js
await NyaTerm.ready;
const session = await NyaTerm.session();
const { output } = await NyaTerm.terminal.read(100);
document.getElementById("output").textContent = output;
```
Use `textContent` when rendering terminal/file/network data. Typings are in
`plugins/sdk/nyaterm.d.ts`. SDK operations return promises and reject on denied
permission, invalid scope, cancellation or runtime errors.
| SDK method | Host method / permission | Behavior |
| ---------------------------------------------- | ------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `session()` | `host/session` / `session.read` | Selected `{id,name,type,connected}` or `null`; no credentials |
| `terminal.read(lines = 100)` | `host/terminal/read` / `terminal.read` | `{output}`; 1–500 recent lines |
| `terminal.execute(command, timeoutMs = 30000)` | `host/terminal/execute` / `terminal.execute` | Individual approval, audit and existing execution engine; 1–120 s execution timeout |
| `filesystem.read(path)` | `host/filesystem/read` / `filesystem.read` | Individual approval; at most 256 KiB UTF-8 text |
| `storage.get(key)` | `host/storage/get` / `storage` | JSON value or `null` |
| `storage.set(key, value)` | `host/storage/set` / `storage` | `null` deletes; otherwise saves nonsecret JSON |
| `network.request(url, options)` | `host/network/request` / `network:https://origin` | GET/POST, declared exact origin, no redirects, 30 s timeout |
| `backend(method, input)` | `ui/*` or declared `command/*` / `native` | Request to persistent backend |
Execute results contain `output`, `exitCode`, `durationMs`, `timedOut` and
`sourceTruncated`. File reads expose `content` (SSH may return extra file metadata).
Local file paths are confined to the selected session's canonical working
directory. Remote reads require SSH/SFTP; serial and Telnet do not offer file reads.
Storage allows 128 portable-ID keys and 1 MiB serialized JSON per plugin. Network
options are `{method, body, contentType}`; request bodies are capped at 256 KiB and
responses at 1 MiB. Results are `{status, contentType, body}` with text bodies.
URLs cannot contain credentials. HTTPS origins include their nondefault port and
must match exactly; no wildcards, paths or implicit subdomain grants.
`NyaTerm.context` contains plugin identity/version and CSS theme variables;
`NyaTerm.onContextChange(listener)` returns an unsubscribe function. Theme variables
are applied to the iframe document automatically. This context contains no scope
token. Tokens are managed by the parent bridge. Scopes expire after 30 minutes;
open panels renew at 25 minutes by recreating their document. Preserve needed
nonsecret preferences in plugin storage.
UI requests are limited to 32 outstanding calls and 1 MiB request parameters.
Host calls have a 180 s envelope including approval; SDK calls time out at 185 s.
An approval dialog times out at 120 s. A native backend request times out at 170 s,
including any reverse host calls, so long commands and approvals share that budget.
## Native backend protocol
Build and package a working persistent-process example:
```sh
pnpm plugin:example:native
pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap
```
Install and explicitly grant `native`. Invoke **Increment native counter** from
the Plugins panel twice: its count increases in the same process. Disable/re-enable
and invoke again: activation starts a fresh process. The build script generates
a manifest for the current OS/architecture. Production plugins should bundle the
executables needed for each supported platform:
```json
{
"executables": {
"windows-x86_64": "bin/backend.exe",
"linux-x86_64": "bin/backend"
},
"transport": "stdio-jsonl"
}
```
Put that object under `backend`, declare the `native` permission, and declare a
command with `"method": "command/count"`. Accepted platforms are Windows, Linux
and macOS on `x86_64` or `aarch64`. Activation is lazy, per plugin; calls reuse the
process and can run concurrently. stdout is exclusively protocol traffic; write
diagnostics to stderr. Environment inheritance is restricted to PATH, OS/temp and
locale values, plus `NYATERM_PLUGIN_ID`, `NYATERM_PLUGIN_VERSION`, `NYATERM_APP_VERSION`.
The working directory is the immutable installed version directory.
The host first sends a JSON-RPC 2.0 request:
```json
{
"id": 1,
"jsonrpc": "2.0",
"method": "plugin/initialize",
"params": {
"pluginId": "example.tools",
"pluginVersion": "1.0.0",
"appVersion": "1.2.12",
"apiVersion": 1,
"protocolVersion": 1,
"permissions": ["native"]
}
}
```
Reply within 10 seconds with matching identity/version/protocol:
```json
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"pluginId": "example.tools",
"pluginVersion": "1.0.0",
"protocolVersion": 1
}
}
```
Normal host requests have numeric IDs and params `{scopeToken, input}`. Respond
with the same numeric ID and `result` or `error:{code,message}`. Backends may make
reverse host requests using a **string ID**, one of the host methods in the table,
and params `{scopeToken, input}`. They must propagate the token associated with the
specific call, not keep a global "current session". Host enforcement checks the
token's plugin, live session/window, version, grants and lock state. Other plugins'
tokens are rejected. Backends should handle `$/cancelRequest` notifications for
timed-out numeric host requests. Closing a scope stops waiting for its response;
native code must arrange its own cooperative work cancellation.
`stdio-jsonl` is one UTF-8 JSON object per newline. `stdio-framed` uses:
```text
kind: u8 | payload length: u32 little-endian | payload
kind 1: UTF-8 JSON-RPC
kind 2: channel length: u16 little-endian | UTF-8 channel | raw bytes
```
Frames are capped at 8 MiB; JSON at 2 MiB; channel names at 128 bytes. The runtime
supports notification/binary subscriptions for future provider integration, but
v1 browser APIs expose request/response calls only. There is no public UI binary
stream or backend event subscription. Do not use those frames to implement a UI
API yet. The runtime bounds queues and pending requests and terminates the child
on protocol failures, failed handshake, disable/update/uninstall, lock or exit.
A crashed backend is activated again on the next call.
## Maintaining the host
The independent runtime lives in `src-tauri/crates/nyaterm-plugin-runtime`; Tauri
adapters and permission checks live in `src-tauri/src/core/plugins`. Plugin UI
bridge code and the SDK are separately tested.
`ipc_transport.js` and `process_ipc_message.js` preserve Tauri 2.11.5 transport
semantics, with a top-frame guard before initializing the invoke key. When
upgrading Tauri, compare these files with its upstream scripts and rerun the IPC
isolation/serialization tests. Do not remove the guard or add `allow-same-origin`
to plugin iframes to work around compatibility issues.
On Windows, the full Tauri library test executable may need a Common Controls v6
activation manifest to load its native UI dependencies. An entrypoint-not-found
error before any tests run is a loader failure; check the executable manifest
rather than interpreting it as a failed plugin assertion.
```sh
pnpm exec vitest run
pnpm build
pnpm lint
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml
cargo test --manifest-path src-tauri/Cargo.toml --lib core::plugins
```
+3
View File
@@ -25,6 +25,9 @@
"build:unlock-rescue": "powershell -ExecutionPolicy Bypass -File scripts/build-unlock-rescue.ps1",
"preview": "vite preview",
"test": "vitest",
"plugin:create": "node scripts/create-plugin.mjs",
"plugin:pack": "node scripts/package-plugin.mjs",
"plugin:example:native": "node scripts/build-plugin-native-example.mjs",
"tauri": "tauri",
"lint": "node scripts/check-no-console.mjs && biome lint src/",
"format": "biome check --write src/",
+17
View File
@@ -0,0 +1,17 @@
# NyaTerm plugin development
Read the [plugin guide](../docs/plugins.md) for installation, manifests,
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.
```sh
pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap
pnpm plugin:example:native
pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap
```
Install the generated packages through the Plugins activity panel and explicitly grant
their requested permissions. Native executables run with OS user permissions.
+247
View File
@@ -0,0 +1,247 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nyaterm-native-counter"
version = "1.0.1"
dependencies = [
"nyaterm-plugin-sdk",
"tokio",
]
[[package]]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,8 @@
[package]
name = "nyaterm-native-counter"
version = "1.0.1"
edition = "2024"
[dependencies]
nyaterm-plugin-sdk = { path = "../../sdk/rust/nyaterm-plugin-sdk" }
tokio = { version = "1", features = ["rt", "macros"] }
@@ -0,0 +1,19 @@
//! A persistent native command using the NyaTerm SDK.
use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json};
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
struct Counter { count: AtomicU64 }
#[async_trait]
impl Plugin for Counter {
async fn call(&self, context: Context, method: &str, _input: Value) -> Result<Value> {
if method != "command/count" { return Err(RpcError::method_not_found(method)); }
let count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
context.log("info", &format!("Counter incremented to {count}")).await?;
Ok(json!({"count":count,"message":"This process is reused until the plugin stops."}))
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { Server::from_env()?.serve(Counter::default()).await }
@@ -0,0 +1,29 @@
{
"contributions": {
"panels": [
{ "id": "toolbox", "title": "Session Toolbox", "entry": "ui/index.html" }
],
"commands": [
{
"id": "open-toolbox",
"title": "Open Session Toolbox",
"panel": "toolbox",
"menus": ["terminal", "connection"]
}
]
},
"description": "Inspect the selected terminal, read recent output and run a command with explicit approval.",
"engine": ">=1.2.12, <2.0.0",
"id": "nyaterm.session-toolbox",
"manifestVersion": 1,
"name": "Session Toolbox",
"permissions": [
"session.read",
"terminal.read",
"terminal.execute",
"filesystem.read",
"storage"
],
"publisher": "NyaTerm example",
"version": "1.0.0"
}
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Session Toolbox</title>
<style>
:root {
color-scheme: dark light;
}
body {
margin: 0;
padding: 16px;
font:
13px/1.5 system-ui,
sans-serif;
background: var(--df-bg, #111827);
color: var(--df-fg, #e5e7eb);
}
h1 {
font-size: 17px;
margin: 0 0 12px;
}
label {
display: block;
margin: 16px 0 6px;
}
input,
button {
font: inherit;
border: 1px solid #64748b;
border-radius: 5px;
padding: 7px 9px;
}
input {
box-sizing: border-box;
width: 100%;
background: transparent;
color: inherit;
}
button {
margin-top: 8px;
cursor: pointer;
background: #2563eb;
color: white;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 10px;
background: #64748b22;
border-radius: 5px;
max-height: 400px;
overflow: auto;
}
#error {
color: #f87171;
}
.hint {
opacity: 0.7;
font-size: 12px;
}
</style>
<script src="nyaterm-sdk.js"></script>
<script src="toolbox.js" defer></script>
</head>
<body>
<h1>Session Toolbox</h1>
<p id="session">Waiting for NyaTerm…</p>
<p class="hint">Operations apply only to this view’s selected session.</p>
<button id="read">Read recent output</button>
<label for="command">Command</label>
<input id="command" placeholder="Enter a command" />
<button id="execute">Request execution</button>
<p class="hint">NyaTerm asks for approval before executing.</p>
<label for="path">Text file path</label>
<input id="path" placeholder="Path in the selected session" />
<button id="file">Request file read</button>
<p id="error" role="alert"></p>
<pre id="output" aria-live="polite"></pre>
</body>
</html>
@@ -0,0 +1,46 @@
"use strict";
const output = document.getElementById("output");
const error = document.getElementById("error");
const buttons = [...document.querySelectorAll("button")];
async function run(operation) {
error.textContent = "";
for (const button of buttons) button.disabled = true;
try {
const result = await operation();
output.textContent =
typeof result === "string" ? result : JSON.stringify(result, null, 2);
} catch (failure) {
error.textContent = failure.message;
} finally {
for (const button of buttons) button.disabled = false;
}
}
document.getElementById("read").addEventListener("click", () =>
run(async () => {
await NyaTerm.storage.set("read-lines", 100);
return NyaTerm.terminal.read(100);
}),
);
document.getElementById("execute").addEventListener("click", () =>
run(async () => {
const command = document.getElementById("command").value;
return NyaTerm.terminal.execute(command);
}),
);
document
.getElementById("file")
.addEventListener("click", () =>
run(() => NyaTerm.filesystem.read(document.getElementById("path").value)),
);
void NyaTerm.ready.then(() =>
run(async () => {
const session = await NyaTerm.session();
document.getElementById("session").textContent = session
? `${session.name} (${session.type})`
: "Select a terminal session and reopen this panel.";
const lines = (await NyaTerm.storage.get("read-lines")) || 100;
document.getElementById("read").textContent = `Read ${lines} lines`;
return "Ready";
}),
);
+48
View File
@@ -0,0 +1,48 @@
/** Include nyaterm-sdk.js as a classic script. Await NyaTerm.ready before host calls. */
type NyaTermJson =
| null
| boolean
| number
| string
| NyaTermJson[]
| { [key: string]: NyaTermJson };
interface NyaTermPluginContext {
pluginId: string;
version: string;
theme: Record<string, string>;
}
interface NyaTermPluginSdk {
readonly ready: Promise<NyaTermPluginContext>;
readonly context: NyaTermPluginContext | null;
onContextChange(
listener: (context: NyaTermPluginContext) => void,
): () => void;
call<T = NyaTermJson>(method: string, params?: NyaTermJson): Promise<T>;
session(): Promise<{
id: string;
name: string;
type: string;
connected: boolean;
} | null>;
terminal: {
read(lines?: number): Promise<{ output: string }>;
execute(command: string, timeoutMs?: number): Promise<NyaTermJson>;
};
filesystem: { read(path: string): Promise<{ content: string }> };
storage: {
get(key: string): Promise<NyaTermJson>;
set(key: string, value: NyaTermJson): Promise<null>;
};
network: {
request(
url: string,
options?: {
method?: "GET" | "POST";
body?: string;
contentType?: string;
},
): Promise<{ status: number; contentType: string; body: string }>;
};
backend<T = NyaTermJson>(method: string, input?: NyaTermJson): Promise<T>;
}
declare const NyaTerm: NyaTermPluginSdk;
+85
View File
@@ -0,0 +1,85 @@
/* NyaTerm Plugin UI SDK v1. Include this as a classic script before plugin code. */
(() => {
"use strict";
const pending = new Map();
const contextListeners = new Set();
let context = null;
let nextId = 0;
let resolveReady;
const ready = new Promise((resolve) => {
resolveReady = resolve;
});
function call(method, params = null) {
if (pending.size >= 32)
return Promise.reject(new Error("Too many pending requests"));
const id = String(++nextId);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("Plugin host request timed out"));
}, 185000);
pending.set(id, { resolve, reject, timer });
window.parent.postMessage(
{ type: "nyaterm-plugin-request", id, method, params },
"*",
);
});
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "nyaterm-plugin-context") {
context = message.context;
for (const [key, value] of Object.entries(context.theme || {})) {
document.documentElement.style.setProperty(key, value);
}
resolveReady(context);
for (const listener of contextListeners) listener(context);
} else if (message.type === "nyaterm-plugin-response") {
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timer);
if (message.error) request.reject(new Error(message.error));
else request.resolve(message.result);
}
});
window.addEventListener("pagehide", () => {
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error("Plugin view closed"));
}
pending.clear();
});
window.NyaTerm = Object.freeze({
ready,
call,
get context() {
return context;
},
onContextChange(listener) {
contextListeners.add(listener);
return () => contextListeners.delete(listener);
},
session: () => call("host/session"),
terminal: Object.freeze({
read: (lines = 100) => call("host/terminal/read", { lines }),
execute: (command, timeoutMs = 30000) =>
call("host/terminal/execute", { command, timeoutMs }),
}),
filesystem: Object.freeze({
read: (path) => call("host/filesystem/read", { path }),
}),
storage: Object.freeze({
get: (key) => call("host/storage/get", { key }),
set: (key, value) => call("host/storage/set", { key, value }),
}),
network: Object.freeze({
request: (url, options = {}) =>
call("host/network/request", { ...options, url }),
}),
backend: (method, input = null) => call(method, input),
});
window.parent.postMessage({ type: "nyaterm-plugin-ready" }, "*");
})();
+239
View File
@@ -0,0 +1,239 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,19 @@
[package]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
description = "Native plugin SDK for NyaTerm's scoped JSON-RPC sidecar protocol"
license = "MIT OR Apache-2.0"
[dependencies]
async-trait = "0.1"
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["io-util", "io-std", "sync", "time", "rt", "macros"] }
tokio-util = { version = "0.7", features = ["rt"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
@@ -0,0 +1,59 @@
# NyaTerm native plugin SDK
This standalone crate implements the native v1 protocol without depending on
Tauri. Generate a working project with:
```sh
pnpm plugin:create example.hello temp/plugins/hello --template rust
node temp/plugins/hello/build.mjs
pnpm plugin:pack temp/plugins/hello temp/plugins/hello.nyap
```
Implement `Plugin::call(Context, method, input)` and launch
`Server::from_env()?.serve(plugin).await`. The host supplies plugin identity,
version and transport in the environment; the SDK validates the initialization
handshake against those values and API/protocol v1. `Plugin::initialize` can
perform additional application checks. JSONL and framed transports use the same
API. Only framed transport supports `Context::send_binary` and `Plugin::binary`.
`Context::host()` exposes `session`, `terminal_read`, `terminal_execute`,
`file_read`, `storage_get`, `storage_set`, and generic `call`/`call_with_timeout`.
The generic API accepts host methods such as `host/network/request` with the same
input documented in the UI SDK. Each client carries its originating request's
scope; it cannot construct another plugin/session scope. Manifest grants and host
approval remain necessary.
Use `RpcError::invalid`, `method_not_found`, or `new` for errors. Returning an
error ends that invocation; it does not stop the backend. Handler panics with
Rust's unwind mode return a generic RPC error; abort panics and process exits
terminate the backend. Do not log request inputs, terminal output or secrets.
`Context::log("info", "Task finished").await` emits a structured diagnostic.
stdout is reserved for protocol traffic; stderr is also collected by the host.
The SDK permits 64 active handlers and 16 outstanding reverse calls. Frames are
bounded to 2 MiB JSON / 8 MiB binary, with 128-byte binary channels. Initialization
has a 10-second deadline, handlers and default host calls 180 seconds; the host
may cancel sooner (NyaTerm backend invocations currently allow 170 seconds).
For shorter reverse deadlines use `call_with_timeout`.
Cancellation drops the async handler and cancels its host client. Dropped/timed-out
reverse calls notify the host to cancel outstanding work. Cloned contexts expire
when their original request finishes. Long work should yield and check
`context.is_cancelled()` or select on `context.cancelled()`. Avoid blocking the
Tokio executor. Cancellation does not undo an already executed terminal command
or automatically stop plugin-owned threads/subprocesses.
Native execution is an OS process with the user's privileges. This SDK supplies
the protocol and scoped host client; it does not create an OS sandbox. The crate
is currently consumed as a local path dependency and is not published on crates.io.
Verification:
```sh
cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture
```
The latter launches an SDK executable through the real host in both transports,
including concurrent/reverse calls, logs, binary frames, cancellation, errors,
panic recovery, crashes and startup failure diagnostics.
@@ -0,0 +1,480 @@
//! Scoped native plugin server. stdout is reserved for protocol traffic.
mod wire;
pub use async_trait::async_trait;
use futures_util::FutureExt;
use serde::{Deserialize, Serialize};
pub use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use wire::Frame;
pub type Result<T> = std::result::Result<T, RpcError>;
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("{message}")]
pub struct RpcError {
pub code: i32,
pub message: String,
}
impl RpcError {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(-32602, message)
}
pub fn method_not_found(method: &str) -> Self {
Self::new(-32601, format!("Unknown method: {method}"))
}
pub fn cancelled() -> Self {
Self::new(-32800, "Request cancelled")
}
}
impl From<std::io::Error> for RpcError {
fn from(error: std::io::Error) -> Self {
Self::new(-32000, error.to_string())
}
}
impl From<serde_json::Error> for RpcError {
fn from(error: serde_json::Error) -> Self {
Self::invalid(error.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Jsonl,
Framed,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Initialization {
pub plugin_id: String,
pub plugin_version: String,
pub app_version: String,
pub api_version: u32,
pub protocol_version: u32,
pub permissions: Vec<String>,
}
#[async_trait]
pub trait Plugin: Send + Sync + 'static {
async fn initialize(&self, _info: Initialization) -> Result<()> {
Ok(())
}
async fn call(&self, context: Context, method: &str, input: Value) -> Result<Value>;
async fn binary(&self, _channel: String, _data: Vec<u8>) -> Result<()> {
Ok(())
}
}
struct Shared {
writer: mpsc::Sender<Frame>,
pending: Mutex<HashMap<String, oneshot::Sender<Result<Value>>>>,
active: Mutex<HashMap<u64, CancellationToken>>,
next_id: AtomicU64,
stop: CancellationToken,
transport: Transport,
}
/// A host client is tied to the scope of one host-owned request.
#[derive(Clone)]
pub struct HostClient {
shared: Arc<Shared>,
scope: String,
cancellation: CancellationToken,
}
struct Pending {
shared: Arc<Shared>,
id: String,
}
struct Active {
shared: Arc<Shared>,
id: u64,
}
impl Drop for Active {
fn drop(&mut self) {
if let Some(token) = self.shared.active.lock().unwrap().remove(&self.id) {
token.cancel();
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if self
.shared
.pending
.lock()
.unwrap()
.remove(&self.id)
.is_some()
{
let frame = Frame::Json(
json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":self.id}}),
);
if let Err(mpsc::error::TrySendError::Full(frame)) = self.shared.writer.try_send(frame)
{
let writer = self.shared.writer.clone();
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(1), writer.send(frame)).await;
});
}
}
}
}
impl HostClient {
pub async fn call(&self, method: &str, input: Value) -> Result<Value> {
self.call_with_timeout(method, input, Duration::from_secs(180))
.await
}
pub async fn call_with_timeout(
&self,
method: &str,
input: Value,
timeout: Duration,
) -> Result<Value> {
if self.cancellation.is_cancelled() || self.shared.stop.is_cancelled() {
return Err(RpcError::cancelled());
}
if !method.starts_with("host/")
|| method.len() > 128
|| serde_json::to_vec(&input)?.len() > 1024 * 1024
{
return Err(RpcError::invalid("Invalid host request"));
}
let id = format!(
"sdk-{}",
self.shared.next_id.fetch_add(1, Ordering::Relaxed)
);
let (sender, receiver) = oneshot::channel();
{
let mut pending = self.shared.pending.lock().unwrap();
if pending.len() >= 16 {
return Err(RpcError::new(-32000, "Too many pending host calls"));
}
pending.insert(id.clone(), sender);
}
let _pending = Pending {
shared: self.shared.clone(),
id: id.clone(),
};
let operation = async {
self.shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"method":method,"params":{"scopeToken":self.scope,"input":input}}))).await.map_err(|_|RpcError::new(-32000,"Protocol writer closed"))?;
receiver
.await
.map_err(|_| RpcError::new(-32000, "Host response channel closed"))?
};
tokio::select! {
_=self.shared.stop.cancelled()=>Err(RpcError::cancelled()),
_=self.cancellation.cancelled()=>Err(RpcError::cancelled()),
result=tokio::time::timeout(timeout,operation)=>result.unwrap_or_else(|_|Err(RpcError::new(-32000,"Host call timed out"))),
}
}
pub async fn session(&self) -> Result<Value> {
self.call("host/session", Value::Null).await
}
pub async fn terminal_read(&self, lines: u16) -> Result<Value> {
self.call("host/terminal/read", json!({"lines":lines}))
.await
}
pub async fn terminal_execute(&self, command: &str, timeout_ms: u64) -> Result<Value> {
self.call(
"host/terminal/execute",
json!({"command":command,"timeoutMs":timeout_ms}),
)
.await
}
pub async fn file_read(&self, path: &str) -> Result<Value> {
self.call("host/filesystem/read", json!({"path":path}))
.await
}
pub async fn storage_get(&self, key: &str) -> Result<Value> {
self.call("host/storage/get", json!({"key":key})).await
}
pub async fn storage_set(&self, key: &str, value: Value) -> Result<Value> {
self.call("host/storage/set", json!({"key":key,"value":value}))
.await
}
}
#[derive(Clone)]
pub struct Context {
host: HostClient,
}
impl Context {
pub fn host(&self) -> &HostClient {
&self.host
}
pub fn is_cancelled(&self) -> bool {
self.host.cancellation.is_cancelled() || self.host.shared.stop.is_cancelled()
}
pub async fn cancelled(&self) {
tokio::select! {_=self.host.cancellation.cancelled()=>{},_=self.host.shared.stop.cancelled()=>{}}
}
pub async fn log(&self, level: &str, message: &str) -> Result<()> {
if !["debug", "info", "warn", "error"].contains(&level) || message.len() > 2048 {
return Err(RpcError::invalid("Invalid log entry"));
}
self.send(Frame::Json(json!({"jsonrpc":"2.0","method":"event/log","params":{"level":level,"message":message}}))).await
}
pub async fn send_binary(&self, channel: &str, data: Vec<u8>) -> Result<()> {
if self.host.shared.transport != Transport::Framed
|| channel.is_empty()
|| channel.len() > 128
|| channel.len() + data.len() + 2 > wire::MAX_FRAME
{
return Err(RpcError::invalid("Invalid binary frame"));
}
self.send(Frame::Binary(channel.to_owned(), data)).await
}
async fn send(&self, frame: Frame) -> Result<()> {
tokio::select! {
_=self.cancelled()=>Err(RpcError::cancelled()),
result=self.host.shared.writer.send(frame)=>result.map_err(|_|RpcError::new(-32000,"Protocol writer closed")),
}
}
}
pub struct Server {
id: String,
version: String,
transport: Transport,
}
impl Server {
pub fn new(id: impl Into<String>, version: impl Into<String>, transport: Transport) -> Self {
Self {
id: id.into(),
version: version.into(),
transport,
}
}
pub fn from_env() -> Result<Self> {
let get = |key| {
std::env::var(key).map_err(|_| {
RpcError::invalid(format!(
"Missing {key}; launch this executable from NyaTerm"
))
})
};
let transport = match std::env::var("NYATERM_PLUGIN_TRANSPORT").as_deref() {
Ok("stdio-framed") => Transport::Framed,
Ok("stdio-jsonl") | Err(_) => Transport::Jsonl,
_ => return Err(RpcError::invalid("Unsupported transport")),
};
Ok(Self::new(
get("NYATERM_PLUGIN_ID")?,
get("NYATERM_PLUGIN_VERSION")?,
transport,
))
}
pub async fn serve(self, plugin: impl Plugin) -> Result<()> {
self.serve_io(plugin, tokio::io::stdin(), tokio::io::stdout())
.await
}
pub async fn serve_io(
self,
plugin: impl Plugin,
reader: impl AsyncRead + Unpin + Send + 'static,
writer: impl AsyncWrite + Unpin + Send + 'static,
) -> Result<()> {
let (sender, mut receiver) = mpsc::channel(8);
let shared = Arc::new(Shared {
writer: sender,
pending: Mutex::new(HashMap::new()),
active: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
stop: CancellationToken::new(),
transport: self.transport,
});
let transport = self.transport;
let mut writer_task = tokio::spawn(async move {
let mut writer = writer;
while let Some(frame) = receiver.recv().await {
wire::write(&mut writer, transport, frame).await?;
}
Ok::<(), RpcError>(())
});
let plugin = Arc::new(plugin);
let mut reader = BufReader::new(reader);
let mut tasks = JoinSet::new();
let result = tokio::select! {
result = self.run(plugin, &mut reader, shared.clone(), &mut tasks) => result,
result = &mut writer_task => result.unwrap_or_else(|_| Err(RpcError::new(-32000, "Protocol writer panicked"))),
};
shared.stop.cancel();
tasks.abort_all();
while tasks.join_next().await.is_some() {}
for (_, pending) in shared.pending.lock().unwrap().drain() {
let _ = pending.send(Err(RpcError::cancelled()));
}
if !writer_task.is_finished() {
writer_task.abort();
let _ = writer_task.await;
}
result
}
async fn run(
&self,
plugin: Arc<impl Plugin>,
reader: &mut (impl AsyncRead + Unpin),
shared: Arc<Shared>,
tasks: &mut JoinSet<Result<()>>,
) -> Result<()> {
let mut initialized = false;
loop {
while let Some(result) = tasks.try_join_next() {
result.map_err(|_| RpcError::new(-32000, "Plugin handler panicked"))??;
}
let Some(frame) = wire::read(reader, self.transport).await? else {
return Ok(());
};
let value = match frame {
Frame::Binary(channel, data) => {
if !initialized {
return Err(RpcError::invalid("Initialize before sending binary frames"));
}
if tasks.len() >= 64 {
return Err(RpcError::invalid("Too many binary handlers"));
}
let plugin = plugin.clone();
tasks.spawn(async move {
tokio::time::timeout(Duration::from_secs(180), plugin.binary(channel, data))
.await
.map_err(|_| RpcError::new(-32000, "Binary handler timed out"))?
});
continue;
}
Frame::Json(value) => value,
};
if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Err(RpcError::invalid("Invalid JSON-RPC version"));
}
let Some(method) = value.get("method").and_then(Value::as_str) else {
let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
RpcError::invalid("Reverse host response IDs must be strings")
})?;
if let Some(sender) = shared.pending.lock().unwrap().remove(id) {
let result = if let Some(error) = value.get("error") {
Err(serde_json::from_value(error.clone())?)
} else {
value
.get("result")
.cloned()
.ok_or_else(|| RpcError::invalid("Host response has no result"))
};
let _ = sender.send(result);
}
continue;
};
if method == "$/cancelRequest" {
if let Some(id) = value.pointer("/params/id").and_then(Value::as_u64)
&& let Some(token) = shared.active.lock().unwrap().get(&id)
{
token.cancel();
}
continue;
}
let id = value
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| RpcError::invalid("Host request IDs must be numeric"))?;
if method == "plugin/initialize" {
if initialized {
return Err(RpcError::invalid("Plugin is already initialized"));
}
let info: Initialization =
serde_json::from_value(value.get("params").cloned().unwrap_or(Value::Null))?;
if info.plugin_id != self.id
|| info.plugin_version != self.version
|| info.api_version != 1
|| info.protocol_version != 1
{
return Err(RpcError::invalid(
"Incompatible initialization identity or API version",
));
}
tokio::time::timeout(Duration::from_secs(10), plugin.initialize(info))
.await
.map_err(|_| RpcError::new(-32000, "Initialization timed out"))??;
initialized = true;
shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"result":{"pluginId":self.id,"pluginVersion":self.version,"protocolVersion":1}})))
.await.map_err(|_| RpcError::new(-32000, "Protocol writer closed"))?;
continue;
}
if !initialized || method.is_empty() || method.len() > 128 {
return Err(RpcError::invalid(
"Plugin is not initialized or method is invalid",
));
}
let scope = value
.pointer("/params/scopeToken")
.and_then(Value::as_str)
.filter(|s| !s.is_empty() && s.len() <= 256)
.ok_or_else(|| RpcError::invalid("Request needs its scope token"))?
.to_owned();
let input = value
.pointer("/params/input")
.cloned()
.unwrap_or(Value::Null);
let cancellation = shared.stop.child_token();
{
let mut active = shared.active.lock().unwrap();
if active.len() >= 64 || active.contains_key(&id) {
return Err(RpcError::invalid(
"Too many requests or duplicate request ID",
));
}
active.insert(id, cancellation.clone());
}
let context = Context {
host: HostClient {
shared: shared.clone(),
scope,
cancellation: cancellation.clone(),
},
};
let plugin = plugin.clone();
let shared = shared.clone();
let method = method.to_owned();
tasks.spawn(async move {
let _active = Active {
shared: shared.clone(),
id,
};
let call = std::panic::AssertUnwindSafe(plugin.call(context, &method, input))
.catch_unwind();
let result = tokio::select! {
_ = cancellation.cancelled() => Err(RpcError::cancelled()),
result = tokio::time::timeout(Duration::from_secs(180), call) => match result {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(RpcError::new(-32000, "Plugin handler panicked")),
Err(_) => Err(RpcError::new(-32000, "Plugin handler timed out")),
},
};
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}),
};
shared
.writer
.send(Frame::Json(response))
.await
.map_err(|_| RpcError::new(-32000, "Protocol writer closed"))
});
}
}
}
@@ -0,0 +1,143 @@
use crate::{Result, RpcError, Transport};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub(crate) const MAX_JSON: usize = 2 * 1024 * 1024;
pub(crate) const MAX_FRAME: usize = 8 * 1024 * 1024;
pub(crate) enum Frame {
Json(Value),
Binary(String, Vec<u8>),
}
pub(crate) async fn read(
reader: &mut (impl AsyncRead + Unpin),
transport: Transport,
) -> Result<Option<Frame>> {
let mut first = [0_u8];
if reader.read(&mut first).await? == 0 {
return Ok(None);
}
if transport == Transport::Jsonl {
let mut bytes = Vec::new();
let mut byte = first[0];
loop {
if byte == b'\n' {
break;
}
if bytes.len() == MAX_JSON {
return Err(RpcError::invalid("JSON line exceeds 2 MiB"));
}
bytes.push(byte);
byte = reader.read_u8().await?;
}
return Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?)));
}
let kind = first[0];
let length = reader.read_u32_le().await? as usize;
if length > MAX_FRAME || (kind == 1 && length > MAX_JSON) {
return Err(RpcError::invalid("Frame is too large"));
}
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).await?;
match kind {
1 => Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?))),
2 if bytes.len() >= 2 => {
let length = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
if length == 0 || length > 128 || length + 2 > bytes.len() {
return Err(RpcError::invalid("Invalid binary channel"));
}
let channel = std::str::from_utf8(&bytes[2..length + 2])
.map_err(|_| RpcError::invalid("Invalid binary channel encoding"))?
.to_owned();
Ok(Some(Frame::Binary(channel, bytes[length + 2..].to_vec())))
}
_ => Err(RpcError::invalid("Invalid frame kind")),
}
}
pub(crate) async fn write(
writer: &mut (impl AsyncWrite + Unpin),
transport: Transport,
frame: Frame,
) -> Result<()> {
let (kind, bytes) = match frame {
Frame::Json(value) => {
let bytes = serde_json::to_vec(&value)?;
if bytes.len() > MAX_JSON {
return Err(RpcError::invalid("JSON frame exceeds 2 MiB"));
}
(1, bytes)
}
Frame::Binary(channel, data) => {
if transport != Transport::Framed
|| channel.is_empty()
|| channel.len() > 128
|| channel.len() + data.len() + 2 > MAX_FRAME
{
return Err(RpcError::invalid("Invalid binary frame"));
}
let mut bytes = (channel.len() as u16).to_le_bytes().to_vec();
bytes.extend_from_slice(channel.as_bytes());
bytes.extend_from_slice(&data);
(2, bytes)
}
};
if transport == Transport::Framed {
writer.write_u8(kind).await?;
writer.write_u32_le(bytes.len() as u32).await?;
}
writer.write_all(&bytes).await?;
if transport == Transport::Jsonl {
writer.write_u8(b'\n').await?;
}
writer.flush().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn distinguishes_clean_eof_from_truncated_frames() {
assert!(
read(
&mut std::io::Cursor::new(Vec::<u8>::new()),
Transport::Jsonl
)
.await
.unwrap()
.is_none()
);
for (transport, bytes) in [
(Transport::Jsonl, b"{\"x\":1}".to_vec()),
(Transport::Framed, vec![1, 2, 0]),
] {
assert!(
read(&mut std::io::Cursor::new(bytes), transport)
.await
.is_err()
);
}
}
#[tokio::test]
async fn rejects_frame_size_before_reading_payload_and_roundtrips_binary() {
let mut bytes = vec![1];
bytes.extend_from_slice(&(MAX_JSON as u32 + 1).to_le_bytes());
let error = read(&mut std::io::Cursor::new(bytes), Transport::Framed)
.await
.err()
.unwrap();
assert!(error.message.contains("too large"));
let mut bytes = Vec::new();
write(
&mut bytes,
Transport::Framed,
Frame::Binary("test".into(), vec![0, 255]),
)
.await
.unwrap();
assert!(
matches!(read(&mut std::io::Cursor::new(bytes),Transport::Framed).await.unwrap(),Some(Frame::Binary(channel,data)) if channel=="test" && data==[0,255])
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, 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 os = { win32: "windows", darwin: "macos", linux: "linux" }[
process.platform
];
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
if (!os || !arch)
throw new Error(
"Unsupported platform; build and add your executable manually",
);
const result = spawnSync(
"cargo",
[
"build",
"--release",
"--manifest-path",
path.join(root, "Cargo.toml"),
"--target-dir",
path.join(root, "target"),
],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
const executable = `nyaterm-plugin-starter${process.platform === "win32" ? ".exe" : ""}`;
const directory = path.join(root, "bin", `${os}-${arch}`);
mkdirSync(directory, { recursive: true });
copyFileSync(
path.join(root, "target/release", executable),
path.join(directory, executable),
);
const manifestPath = path.join(root, "manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.backend.executables[`${os}-${arch}`] =
`bin/${os}-${arch}/${executable}`;
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write("Built native backend; the plugin is ready to package.\n");
+22
View File
@@ -0,0 +1,22 @@
use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json};
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
struct Hello { count: AtomicU64 }
#[async_trait]
impl Plugin for Hello {
async fn call(&self, context: Context, method: &str, _input: Value) -> Result<Value> {
match method {
"ui/hello" | "command/hello" => {
let count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
context.log("info", "Hello command completed").await?;
Ok(json!({"message": "Hello from Rust", "count": count}))
}
_ => Err(RpcError::method_not_found(method)),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { Server::from_env()?.serve(Hello::default()).await }
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NyaTerm plugin starter</title>
<style>
body {
margin: 16px;
font: 14px/1.5 system-ui;
background: var(--df-bg, #111827);
color: var(--df-fg, #e5e7eb);
}
button {
padding: 8px 12px;
font: inherit;
cursor: pointer;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<h1>Hello, NyaTerm</h1>
<button id="run" type="button">Run example</button>
<pre id="output" aria-live="polite"></pre>
<script src="nyaterm-sdk.js"></script>
<script src="main.js"></script>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = fileURLToPath(new URL("../", import.meta.url));
const example = path.join(root, "plugins/examples/native-counter");
const result = spawnSync(
"cargo",
["build", "--manifest-path", path.join(example, "Cargo.toml")],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
const os = { win32: "windows", darwin: "macos", linux: "linux" }[
process.platform
];
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
if (!os || !arch) throw new Error("Unsupported native example platform");
const executable = `nyaterm-native-counter${process.platform === "win32" ? ".exe" : ""}`;
const directory = path.join(root, "temp/plugins/native-counter");
mkdirSync(path.join(directory, "bin"), { recursive: true });
copyFileSync(
path.join(example, "target/debug", executable),
path.join(directory, "bin", executable),
);
const { version } = JSON.parse(
readFileSync(path.join(root, "package.json"), "utf8"),
);
writeFileSync(
path.join(directory, "manifest.json"),
JSON.stringify(
{
manifestVersion: 1,
id: "nyaterm.native-counter",
name: "Native Counter",
version: "1.0.1",
description:
"Demonstrates a persistent native command. Each invocation increments the process counter.",
publisher: "NyaTerm example",
engine: `>=${version}, <2.0.0`,
permissions: ["native"],
backend: {
transport: "stdio-jsonl",
executables: { [`${os}-${arch}`]: `bin/${executable}` },
},
contributions: {
commands: [
{
id: "count",
title: "Increment native counter",
method: "command/count",
menus: ["terminal", "connection"],
},
],
},
},
null,
2,
),
);
process.stdout.write(
`Native example prepared in ${directory}\nPackage with: pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap\n`,
);
+131
View File
@@ -0,0 +1,131 @@
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("../", import.meta.url));
const args = process.argv.slice(2);
const [id, destination, option, template = "ui"] = args;
if (
args.length < 2 ||
args.length > 4 ||
(option && option !== "--template") ||
(option && args.length !== 4) ||
!["ui", "rust"].includes(template)
) {
process.stderr.write(
"Usage: pnpm plugin:create <publisher.plugin> <directory> [--template ui|rust]\n",
);
process.exit(1);
}
if (
!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id) ||
!id.includes(".") ||
id.includes("..")
) {
throw new Error(
"Use a namespaced lowercase plugin ID, for example example.hello",
);
}
const target = path.resolve(destination);
if (existsSync(target))
throw new Error(`Destination already exists: ${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 manifest = {
manifestVersion: 1,
id,
name: id,
version: "1.0.0",
description: "A NyaTerm plugin starter",
publisher: id.split(".")[0],
engine: `>=${version}, <2.0.0`,
permissions: template === "rust" ? ["native"] : ["session.read"],
contributions: {
panels: [{ id: "main", title: id, entry: "ui/index.html" }],
commands: [
{
id: "hello",
title: `Open ${id}`,
panel: "main",
menus: ["terminal", "connection"],
},
],
},
};
if (template === "rust") {
manifest.backend = { transport: "stdio-jsonl", executables: {} };
manifest.contributions.commands.push({
id: "run",
title: "Run native hello",
method: "command/hello",
menus: ["terminal", "connection"],
});
}
mkdirSync(path.dirname(target), { recursive: true });
const stage = mkdtempSync(path.join(path.dirname(target), ".nyaterm-plugin-"));
try {
const put = (file, content) => {
const output = path.join(stage, file);
mkdirSync(path.dirname(output), { recursive: true });
writeFileSync(output, content);
};
put("manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
put(".gitignore", "target/\nbin/\n*.nyap\n");
put(
"ui/index.html",
readFileSync(path.join(root, "plugins/templates/ui/index.html"), "utf8"),
);
put(
"ui/main.js",
template === "rust"
? 'document.getElementById("run").onclick = async () => {\n const output = document.getElementById("output");\n try { await NyaTerm.ready; output.textContent = JSON.stringify(await NyaTerm.backend("ui/hello"), null, 2); }\n catch (error) { output.textContent = error.message; }\n};\n'
: 'document.getElementById("run").onclick = async () => {\n const output = document.getElementById("output");\n try { await NyaTerm.ready; output.textContent = JSON.stringify(await NyaTerm.session(), null, 2); }\n catch (error) { output.textContent = error.message; }\n};\n',
);
put(
"ui/nyaterm-sdk.js",
readFileSync(path.join(root, "plugins/sdk/nyaterm.js")),
);
put(
"ui/nyaterm-sdk.d.ts",
readFileSync(path.join(root, "plugins/sdk/nyaterm.d.ts")),
);
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`,
);
put(
"src/main.rs",
readFileSync(path.join(root, "plugins/templates/rust/main.rs")),
);
put(
"build.mjs",
readFileSync(path.join(root, "plugins/templates/rust/build.mjs")),
);
}
const packer = path.join(root, "scripts/package-plugin.mjs");
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`,
);
renameSync(stage, target);
} catch (error) {
rmSync(stage, { recursive: true, force: true });
throw error;
}
process.stdout.write(
`Created ${template} plugin ${id} at ${target}\nSee README.md for build and packaging instructions.\n`,
);
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
const script = fileURLToPath(new URL("./create-plugin.mjs", import.meta.url));
function withDirectory(fn) {
const directory = mkdtempSync(path.join(os.tmpdir(), "nyaterm scaffold "));
try {
fn(directory);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
test("generates UI source and refuses to overwrite an existing project", () =>
withDirectory((directory) => {
const target = path.join(directory, "ui");
assert.equal(
spawnSync(process.execPath, [script, "example.hello", target]).status,
0,
);
const original = readFileSync(path.join(target, "manifest.json"), "utf8");
assert.equal(JSON.parse(original).permissions[0], "session.read");
assert.ok(existsSync(path.join(target, "ui/nyaterm-sdk.js")));
assert.notEqual(
spawnSync(process.execPath, [script, "example.changed", target]).status,
0,
);
assert.equal(
readFileSync(path.join(target, "manifest.json"), "utf8"),
original,
);
}));
test("generates Rust source with a usable SDK path outside the repository", () =>
withDirectory((directory) => {
const target = path.join(directory, "rust");
assert.equal(
spawnSync(process.execPath, [
script,
"example.hello",
target,
"--template",
"rust",
]).status,
0,
);
const cargo = readFileSync(path.join(target, "Cargo.toml"), "utf8");
const sdk = JSON.parse(cargo.match(/path = (".*")/)[1]);
assert.ok(existsSync(path.resolve(target, sdk, "Cargo.toml")));
assert.ok(
readFileSync(path.join(target, "src/main.rs"), "utf8").includes(
"Server::from_env",
),
);
assert.ok(existsSync(path.join(target, "build.mjs")));
}));
test("rejects invalid IDs and template arguments without creating files", () =>
withDirectory((directory) => {
const target = path.join(directory, "invalid");
for (const args of [
["../escape", target],
["no-namespace", target],
["example.test", target, "--template", "unknown"],
["example.test", target, "--template"],
]) {
assert.notEqual(spawnSync(process.execPath, [script, ...args]).status, 0);
assert.equal(existsSync(target), false);
}
}));
+34
View File
@@ -0,0 +1,34 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const args = process.argv.slice(2);
if (args.length !== 2) {
process.stderr.write(
"Usage: pnpm plugin:pack <plugin-directory> <output.nyap>\n",
);
process.exit(1);
}
const version = JSON.parse(
readFileSync(path.join(root, "package.json"), "utf8"),
).version;
const result = spawnSync(
"cargo",
[
"run",
"--manifest-path",
path.join(root, "src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml"),
"--bin",
"nyaterm-plugin",
"--",
"pack",
path.resolve(args[0]),
path.resolve(args[1]),
version,
],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
if (result.error) process.stderr.write(`${result.error.message}\n`);
process.exit(result.status ?? 1);
+21
View File
@@ -4942,6 +4942,7 @@ dependencies = [
"nucleo-matcher",
"nyaterm-mcp-protocol",
"nyaterm-otp",
"nyaterm-plugin-runtime",
"open",
"opendal",
"pbkdf2 0.12.2",
@@ -5008,6 +5009,26 @@ dependencies = [
name = "nyaterm-otp"
version = "0.1.0"
[[package]]
name = "nyaterm-plugin-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"hex",
"regex",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tokio-util",
"url",
"uuid",
"zip 8.6.0",
]
[[package]]
name = "objc2"
version = "0.6.4"
+1
View File
@@ -87,6 +87,7 @@ tokio = { version = "1", features = ["test-util"] }
[dependencies]
nyaterm-otp = { path = "crates/otp" }
nyaterm-mcp-protocol = { path = "crates/nyaterm-mcp-protocol" }
nyaterm-plugin-runtime = { path = "crates/nyaterm-plugin-runtime" }
tauri = { version = "2", features = ["tray-icon", "protocol-asset"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
+964
View File
@@ -0,0 +1,964 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"zlib-rs",
]
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "nyaterm-plugin-runtime"
version = "0.1.0"
dependencies = [
"async-trait",
"hex",
"nyaterm-plugin-sdk",
"regex",
"semver",
"serde",
"serde_json",
"sha2",
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"url",
"uuid",
"zip",
]
[[package]]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "regex"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tinystr"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
version = "1.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
dependencies = [
"getrandom",
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"crc32fast",
"flate2",
"indexmap",
"memchr",
"typed-path",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
@@ -0,0 +1,36 @@
[package]
name = "nyaterm-plugin-runtime"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
[dependencies]
async-trait = "0.1"
hex = "0.4"
regex = "1"
semver = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tempfile = "3"
thiserror = "2"
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt", "macros"] }
tokio-util = { version = "0.7", features = ["rt"] }
url = "2"
uuid = { version = "1", features = ["v4"] }
zip = { version = "8", default-features = false, features = ["deflate"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
[features]
sdk-fixture = ["dep:nyaterm-plugin-sdk"]
[dependencies.nyaterm-plugin-sdk]
path = "../../../plugins/sdk/rust/nyaterm-plugin-sdk"
optional = true
[[bin]]
name = "sdk-fixture"
path = "src/bin/sdk-fixture.rs"
required-features = ["sdk-fixture"]
@@ -0,0 +1,105 @@
//! Package authoring CLI. Run through `pnpm plugin:pack` or directly with Cargo.
use nyaterm_plugin_runtime::manifest::{Manifest, validate_path};
use nyaterm_plugin_runtime::package::prepare;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use zip::write::SimpleFileOptions;
fn main() {
if let Err(error) = run() {
eprintln!("{error}");
std::process::exit(1);
}
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match args.first().map(String::as_str) {
Some("pack") if args.len() == 4 => {
let source = PathBuf::from(&args[1]).canonicalize()?;
let output = PathBuf::from(&args[2]);
let manifest = Manifest::parse(&std::fs::read(source.join("manifest.json"))?, &args[3])?;
let mut files = BTreeMap::new();
files.insert("manifest.json".into(), std::fs::read(source.join("manifest.json"))?);
for directory in ["ui", "assets", "bin"] {
if source.join(directory).exists() { collect(&source, &source.join(directory), &mut files)?; }
}
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::<usize>() > 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)?;
let checksums = files.iter().map(|(name, bytes)| (name.clone(), hex::encode(Sha256::digest(bytes)))).collect::<BTreeMap<_, _>>();
{
let mut archive = zip::ZipWriter::new(staging.as_file_mut());
for (name, bytes) in files {
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(if name.starts_with("bin/") { 0o755 } else { 0o644 });
archive.start_file(name, options)?;
archive.write_all(&bytes)?;
}
archive.start_file("checksums.json", SimpleFileOptions::default())?;
archive.write_all(&serde_json::to_vec_pretty(&checksums)?)?;
archive.finish()?;
}
// Run the real installer checks before publishing the archive.
let staging_parent = tempfile::tempdir()?;
let validated = prepare(staging.path(), staging_parent.path(), &args[3])?;
staging.as_file().sync_all()?;
staging.persist(&output).map_err(|e| e.error)?;
println!("Packaged {} {} -> {}\nSHA-256: {}", manifest.id, manifest.version, output.display(), validated.preview.digest);
}
Some("inspect") if args.len() == 3 => {
let staging = tempfile::tempdir()?;
let package = prepare(Path::new(&args[1]), staging.path(), &args[2])?;
println!("{}", serde_json::to_string_pretty(&package.preview)?);
}
_ => return Err("Usage: nyaterm-plugin pack <directory> <output.nyap> <app-version>\n nyaterm-plugin inspect <package.nyap> <app-version>".into()),
}
Ok(())
}
fn collect(
root: &Path,
path: &Path,
files: &mut BTreeMap<String, Vec<u8>>,
) -> Result<(), Box<dyn std::error::Error>> {
let metadata = std::fs::symlink_metadata(path)?;
if metadata.file_type().is_symlink() {
return Err("Plugin source cannot contain symlinks".into());
}
if metadata.is_dir() {
for entry in std::fs::read_dir(path)? {
collect(root, &entry?.path(), files)?;
}
} else if metadata.is_file() {
let relative = path
.strip_prefix(root)?
.to_str()
.ok_or("Non-UTF-8 plugin path")?
.replace('\\', "/");
validate_path(&relative)?;
if files.len() >= 1023 || metadata.len() > 64 * 1024 * 1024 {
return Err("Plugin source is too large".into());
}
if files.values().map(Vec::len).sum::<usize>() + metadata.len() as usize > 256 * 1024 * 1024
{
return Err("Plugin source exceeds the expanded package limit".into());
}
let mut bytes = Vec::new();
std::fs::File::open(path)?
.take(64 * 1024 * 1024 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > 64 * 1024 * 1024 {
return Err("Plugin file is too large".into());
}
files.insert(relative, bytes);
} else {
return Err("Plugin source cannot contain special files".into());
}
Ok(())
}
@@ -0,0 +1,40 @@
//! A small protocol peer used by process-level runtime tests and as a native example.
use serde_json::{Value, json};
use std::io::{BufRead, Write};
fn main() {
let stdin = std::io::stdin();
let mut lines = stdin.lock().lines();
let mut stdout = std::io::stdout().lock();
while let Some(Ok(line)) = lines.next() {
let request: Value = serde_json::from_str(&line).unwrap();
let Some(method) = request.get("method").and_then(Value::as_str) else {
continue;
};
let result = match method {
"plugin/initialize" => {
let id = std::env::var("NYATERM_PLUGIN_ID").unwrap();
json!({"pluginId":id,"pluginVersion":if id == "bad.handshake" { "9.9.9".to_string() } else { std::env::var("NYATERM_PLUGIN_VERSION").unwrap() },"protocolVersion":1})
}
"ui/echo" | "command/hello" => request.get("params").cloned().unwrap_or(Value::Null),
"ui/timeout" => continue,
"ui/crash" => std::process::exit(7),
"ui/host" => {
writeln!(stdout, "{}", json!({"jsonrpc":"2.0","id":"host-1","method":"host/session","params":{"scopeToken":"fixture-scope"}})).unwrap();
stdout.flush().unwrap();
let response: Value =
serde_json::from_str(&lines.next().unwrap().unwrap()).unwrap();
response.get("result").cloned().unwrap_or(Value::Null)
}
"$/cancelRequest" => continue,
_ => Value::Null,
};
writeln!(
stdout,
"{}",
json!({"jsonrpc":"2.0","id":request["id"],"result":result})
)
.unwrap();
stdout.flush().unwrap();
}
}
@@ -0,0 +1,77 @@
use nyaterm_plugin_sdk::{
Context, Initialization, Plugin, Result, RpcError, Server, Value, async_trait, json,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
#[derive(Default)]
struct Fixture {
saved: std::sync::Mutex<Option<Context>>,
count: AtomicUsize,
completed: AtomicUsize,
binary: AtomicUsize,
}
#[async_trait]
impl Plugin for Fixture {
async fn initialize(&self, info: Initialization) -> Result<()> {
eprintln!("SDK initialization started");
if info.plugin_id == "bad.sdk" {
return Err(RpcError::invalid("Fixture handshake failure"));
}
Ok(())
}
async fn call(&self, context: Context, method: &str, input: Value) -> Result<Value> {
match method {
"ui/save-context" => {
*self.saved.lock().unwrap() = Some(context);
Ok(Value::Null)
}
"ui/reuse-context" => {
let context = self
.saved
.lock()
.unwrap()
.clone()
.ok_or_else(|| RpcError::invalid("No saved context"))?;
context.host().session().await
}
"ui/echo" => Ok(input),
"ui/count" => Ok(json!(self.count.fetch_add(1, Ordering::Relaxed) + 1)),
"ui/host" => context.host().session().await,
"ui/log" => {
context.log("error", "Fixture diagnostic error").await?;
Ok(Value::Null)
}
"ui/slow" => {
tokio::time::sleep(Duration::from_secs(1)).await;
self.completed.fetch_add(1, Ordering::Relaxed);
Ok(Value::Null)
}
"ui/completed" => Ok(json!(self.completed.load(Ordering::Relaxed))),
"ui/host-timeout" => {
context
.host()
.call_with_timeout("host/slow", Value::Null, Duration::from_millis(20))
.await
}
"ui/binary" => {
context.send_binary("fixture", vec![1, 2, 3]).await?;
Ok(json!(self.binary.load(Ordering::Relaxed)))
}
"ui/panic" => panic!("fixture panic"),
"ui/crash" => std::process::exit(9),
_ => Err(RpcError::method_not_found(method)),
}
}
async fn binary(&self, channel: String, data: Vec<u8>) -> Result<()> {
if channel != "fixture" || data != [3, 2, 1] {
return Err(RpcError::invalid("Unexpected binary input"));
}
self.binary.fetch_add(1, Ordering::Relaxed);
Ok(())
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
Server::from_env()?.serve(Fixture::default()).await
}
@@ -0,0 +1,288 @@
//! Bounded, memory-only diagnostics. Tickets discard events from replaced activations.
use serde::Serialize;
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
const MAX_ENTRIES: usize = 100;
const MAX_BYTES: usize = 64 * 1024;
const MAX_MESSAGE: usize = 2048;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Idle,
Starting,
Running,
Stopped,
Error,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LogEntry {
pub timestamp: u64,
pub version: String,
pub level: String,
pub source: String,
pub message: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub status: Status,
pub version: String,
pub last_error: Option<String>,
pub logs: VecDeque<LogEntry>,
}
struct Record {
generation: u64,
snapshot: Snapshot,
}
#[derive(Default)]
struct State {
next: u64,
records: HashMap<String, Record>,
}
#[derive(Clone, Default)]
pub struct Diagnostics {
state: Arc<Mutex<State>>,
}
#[derive(Clone)]
pub struct Ticket {
id: String,
generation: u64,
}
impl Diagnostics {
pub fn begin(&self, id: &str, version: &str) -> Ticket {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next += 1;
let generation = state.next;
let record = state.records.entry(id.into()).or_insert_with(|| Record {
generation,
snapshot: Snapshot {
status: Status::Idle,
version: version.into(),
last_error: None,
logs: VecDeque::new(),
},
});
record.generation = generation;
record.snapshot.status = Status::Starting;
record.snapshot.version = version.into();
record.snapshot.last_error = None;
append(
&mut record.snapshot,
"info",
"host",
"Starting native backend",
);
Ticket {
id: id.into(),
generation,
}
}
pub fn update(
&self,
ticket: &Ticket,
status: Option<Status>,
level: &str,
source: &str,
message: &str,
) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(record) = state
.records
.get_mut(&ticket.id)
.filter(|r| r.generation == ticket.generation)
{
// Protocol failures remain errors when the process subsequently reports its exit.
if let Some(status) = status
&& !(status == Status::Running && record.snapshot.status != Status::Starting)
{
record.snapshot.status = status;
}
append(&mut record.snapshot, level, source, message);
}
}
pub fn ticket(&self, id: &str) -> Option<Ticket> {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.records.get(id).map(|r| Ticket {
id: id.into(),
generation: r.generation,
})
}
pub fn stop(&self, id: &str) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state.next += 1;
let generation = state.next;
if let Some(record) = state.records.get_mut(id) {
record.generation = generation;
record.snapshot.status = Status::Stopped;
append(
&mut record.snapshot,
"info",
"host",
"Native backend stopped",
);
}
}
pub fn snapshot(&self, id: &str, version: &str) -> Snapshot {
let state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
state
.records
.get(id)
.map(|r| {
let mut snapshot = r.snapshot.clone();
if snapshot.version != version {
snapshot.version = version.into();
snapshot.status = Status::Stopped;
snapshot.last_error = None;
}
snapshot
})
.unwrap_or_else(|| Snapshot {
status: Status::Idle,
version: version.into(),
last_error: None,
logs: VecDeque::new(),
})
}
pub fn clear(&self, id: &str) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(record) = state.records.get_mut(id) {
record.snapshot.logs.clear();
record.snapshot.last_error = None;
}
}
pub fn remove(&self, id: &str) {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.records
.remove(id);
}
}
fn append(snapshot: &mut Snapshot, level: &str, source: &str, message: &str) {
// Scope tokens are UUIDs. Also mask common credential assignments, even in stderr.
static UUID: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")
.unwrap()
});
static SECRET: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r#"(?i)(scopeToken|password|passwd|token|api[_-]?key|secret|authorization)[\s"']*[:=][\s"']*[^\s,;"'}]+"#).unwrap()
});
let message = SECRET.replace_all(message, "$1=[redacted]");
let message = UUID.replace_all(&message, "[scope]");
let mut message: String = message
.chars()
.filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
.collect();
if message.len() > MAX_MESSAGE {
let mut end = MAX_MESSAGE - 3;
while !message.is_char_boundary(end) {
end -= 1;
}
message.truncate(end);
message.push_str("...");
}
let level = if ["debug", "info", "warn", "error"].contains(&level) {
level
} else {
"info"
};
if level == "error" {
snapshot.last_error = Some(message.clone());
}
snapshot.logs.push_back(LogEntry {
timestamp: SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64,
version: snapshot.version.clone(),
level: level.into(),
source: source.chars().take(64).collect(),
message,
});
while snapshot.logs.len() > MAX_ENTRIES
|| snapshot
.logs
.iter()
.map(|entry| entry.message.len() + entry.source.len() + entry.version.len())
.sum::<usize>()
> MAX_BYTES
{
snapshot.logs.pop_front();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bounds_redacts_and_discards_old_activations() {
let store = Diagnostics::default();
let old = store.begin("example.test", "1.0.0");
store.update(
&old,
Some(Status::Error),
"error",
"stderr",
"password=hunter2 scopeToken=12345678-1234-1234-1234-123456789abc",
);
let snapshot = store.snapshot("example.test", "1.0.0");
assert!(!snapshot.last_error.unwrap().contains("hunter2"));
for _ in 0..110 {
store.update(&old, None, "info", "sdk", &"测".repeat(2000));
}
let snapshot = store.snapshot("example.test", "1.0.0");
assert!(snapshot.logs.len() <= 100);
assert!(
snapshot
.logs
.iter()
.map(|e| e.message.len() + e.source.len() + e.version.len())
.sum::<usize>()
<= MAX_BYTES
);
store.stop("example.test");
store.update(&old, Some(Status::Error), "error", "host", "late crash");
assert_eq!(
store.snapshot("example.test", "1.0.0").status,
Status::Stopped
);
let new = store.begin("example.test", "2.0.0");
store.update(&new, Some(Status::Running), "info", "host", "Started");
store.update(&old, Some(Status::Error), "error", "host", "late crash");
assert_eq!(
store.snapshot("example.test", "2.0.0").status,
Status::Running
);
store.clear("example.test");
assert!(store.snapshot("example.test", "2.0.0").logs.is_empty());
store.remove("example.test");
store.update(&new, Some(Status::Error), "error", "host", "late crash");
assert_eq!(store.snapshot("example.test", "2.0.0").status, Status::Idle);
}
}
@@ -0,0 +1,26 @@
//! Tauri-independent installation, manifest validation and sidecar transport.
pub mod diagnostics;
pub mod manifest;
pub mod package;
pub mod registry;
pub mod sidecar;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("{0}")]
Invalid(String),
#[error("{0}")]
Runtime(String),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Zip(#[from] zip::result::ZipError),
}
pub type Result<T> = std::result::Result<T, Error>;
pub(crate) fn invalid(message: impl Into<String>) -> Error {
Error::Invalid(message.into())
}
@@ -0,0 +1,312 @@
use crate::{Result, invalid};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
pub const API_VERSION: u32 = 1;
pub const MAX_MANIFEST_BYTES: usize = 128 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Manifest {
pub manifest_version: u32,
pub id: String,
pub name: String,
pub version: String,
pub description: String,
pub publisher: String,
pub engine: String,
#[serde(default)]
pub permissions: Vec<String>,
#[serde(default)]
pub contributions: Contributions,
pub backend: Option<Backend>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Contributions {
#[serde(default)]
pub panels: Vec<Panel>,
#[serde(default)]
pub commands: Vec<Command>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Panel {
pub id: String,
pub title: String,
pub entry: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Command {
pub id: String,
pub title: String,
pub panel: Option<String>,
pub method: Option<String>,
#[serde(default)]
pub menus: Vec<MenuLocation>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum MenuLocation {
Terminal,
Connection,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct Backend {
pub transport: Transport,
/// Keys such as windows-x86_64, linux-aarch64 and macos-aarch64.
pub executables: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Transport {
StdioJsonl,
StdioFramed,
}
pub fn valid_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b".-_".contains(&b))
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& !value.contains("..")
}
/// Portable archive paths; rejects Windows aliases even when installed on Unix.
pub fn validate_path(value: &str) -> Result<()> {
if value.is_empty() || value.len() > 240 || value.contains(['\\', ':', '\0']) {
return Err(invalid("Invalid plugin asset path"));
}
for component in value.split('/') {
if component.is_empty()
|| component == "."
|| component == ".."
|| component.ends_with(['.', ' '])
|| component.chars().any(char::is_control)
|| component.contains(['<', '>', '"', '|', '?', '*'])
{
return Err(invalid("Invalid plugin asset path"));
}
let stem = component
.split('.')
.next()
.unwrap_or_default()
.to_ascii_uppercase();
if [
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7",
"COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
]
.contains(&stem.as_str())
{
return Err(invalid("Reserved plugin asset path"));
}
}
Ok(())
}
pub fn network_origin(permission: &str) -> Option<&str> {
permission.strip_prefix("network:")
}
pub fn validate_permission(permission: &str) -> Result<()> {
if [
"session.read",
"terminal.read",
"terminal.execute",
"filesystem.read",
"storage",
"native",
]
.contains(&permission)
{
return Ok(());
}
if let Some(origin) = network_origin(permission) {
let url = url::Url::parse(origin).map_err(|_| invalid("Invalid network permission"))?;
if url.scheme() == "https"
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.path() == "/"
&& url.query().is_none()
&& url.fragment().is_none()
&& url.origin().ascii_serialization() == origin
{
return Ok(());
}
}
Err(invalid(format!(
"Unknown or invalid permission: {permission}"
)))
}
impl Manifest {
pub fn parse(bytes: &[u8], app_version: &str) -> Result<Self> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(invalid("Plugin manifest is too large"));
}
let manifest: Self = serde_json::from_slice(bytes)?;
manifest.validate(app_version)?;
Ok(manifest)
}
pub fn validate(&self, app_version: &str) -> Result<()> {
if self.manifest_version != API_VERSION || !valid_id(&self.id) || !self.id.contains('.') {
return Err(invalid("Unsupported manifest version or invalid plugin ID"));
}
for (name, value, limit) in [
("name", &self.name, 120),
("description", &self.description, 2000),
("publisher", &self.publisher, 120),
] {
if value.trim().is_empty() || value.len() > limit || value.chars().any(|c| c == '\0') {
return Err(invalid(format!("Invalid plugin {name}")));
}
}
let version =
semver::Version::parse(&self.version).map_err(|_| invalid("Invalid plugin version"))?;
if version.to_string() != self.version || self.version.len() > 80 {
return Err(invalid("Invalid plugin version"));
}
let requirement = semver::VersionReq::parse(&self.engine)
.map_err(|_| invalid("Invalid engine requirement"))?;
let app =
semver::Version::parse(app_version).map_err(|_| invalid("Invalid app version"))?;
if !requirement.matches(&app) {
return Err(invalid("Plugin is incompatible with this NyaTerm version"));
}
if self.permissions.len() > 24
|| self.contributions.panels.len() > 16
|| self.contributions.commands.len() > 64
{
return Err(invalid("Too many plugin permissions or contributions"));
}
let mut permissions = HashSet::new();
for permission in &self.permissions {
validate_permission(permission)?;
if !permissions.insert(permission) {
return Err(invalid("Duplicate permission"));
}
}
if self
.permissions
.iter()
.filter(|p| network_origin(p).is_some())
.count()
> 8
{
return Err(invalid("Too many network origins"));
}
let mut ids = HashSet::new();
for panel in &self.contributions.panels {
if !valid_id(&panel.id)
|| panel.title.trim().is_empty()
|| panel.title.len() > 120
|| !ids.insert(&panel.id)
{
return Err(invalid("Invalid or duplicate panel"));
}
validate_path(&panel.entry)?;
if !panel.entry.starts_with("ui/") || !panel.entry.ends_with(".html") {
return Err(invalid("Panel entry must be HTML"));
}
}
for command in &self.contributions.commands {
if !valid_id(&command.id)
|| command.title.trim().is_empty()
|| command.title.len() > 120
|| !ids.insert(&command.id)
{
return Err(invalid("Invalid or duplicate command"));
}
match (&command.panel, &command.method) {
(Some(panel), None) if self.contributions.panels.iter().any(|p| &p.id == panel) => {
}
(None, Some(method))
if self.backend.is_some()
&& method.starts_with("command/")
&& method.len() <= 128 => {}
_ => return Err(invalid("Command must reference a panel or backend method")),
}
if command.menus.len() > 2 {
return Err(invalid("Too many command menu locations"));
}
}
if let Some(backend) = &self.backend {
if !self.permissions.iter().any(|p| p == "native")
|| backend.executables.is_empty()
|| backend.executables.len() > 8
{
return Err(invalid(
"Native backend requires the native permission and executables",
));
}
for (platform, path) in &backend.executables {
if ![
"windows-x86_64",
"windows-aarch64",
"linux-x86_64",
"linux-aarch64",
"macos-x86_64",
"macos-aarch64",
]
.contains(&platform.as_str())
{
return Err(invalid("Unsupported backend platform"));
}
validate_path(path)?;
if !path.starts_with("bin/") {
return Err(invalid("Backend executable must be under bin/"));
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_nonportable_and_traversing_paths() {
for path in [
"../secret",
"/absolute",
"C:/x",
"ui\\x",
"ui/a:stream",
"ui/CON.html",
"ui/a.",
"ui/a ",
"ui//a",
"ui/./a",
] {
assert!(validate_path(path).is_err(), "{path}");
}
assert!(validate_path("ui/index.html").is_ok());
}
#[test]
fn network_permissions_are_exact_https_origins() {
for value in [
"network:*",
"network:http://example.com",
"network:https://a.com/path",
"network:https://u@a.com",
"network:https://a.com/",
"network:https://a.com?x",
] {
assert!(validate_permission(value).is_err(), "{value}");
}
assert!(validate_permission("network:https://example.com:8443").is_ok());
}
}
@@ -0,0 +1,220 @@
use crate::manifest::{Manifest, validate_path};
use crate::{Result, invalid};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet};
use std::fs::File;
use std::io::Read;
use std::path::{Path, PathBuf};
pub const MAX_PACKAGE_BYTES: u64 = 128 * 1024 * 1024;
const MAX_EXPANDED_BYTES: u64 = 256 * 1024 * 1024;
const MAX_FILE_BYTES: u64 = 64 * 1024 * 1024;
const MAX_FILES: usize = 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PackagePreview {
pub manifest: Manifest,
pub digest: String,
pub expanded_bytes: u64,
}
pub struct PreparedPackage {
pub preview: PackagePreview,
pub directory: tempfile::TempDir,
}
/// Inspect and extract into a private staging directory. Nothing becomes active here.
pub fn prepare(path: &Path, staging_parent: &Path, app_version: &str) -> Result<PreparedPackage> {
let mut file = File::open(path)?;
if file.metadata()?.len() > MAX_PACKAGE_BYTES {
return Err(invalid("Plugin package is too large"));
}
let mut digest = Sha256::new();
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
digest.update(&buffer[..read]);
}
use std::io::{Seek, SeekFrom};
file.seek(SeekFrom::Start(0))?;
let package_digest = hex::encode(digest.finalize());
let mut archive = zip::ZipArchive::new(file)?;
if archive.len() > MAX_FILES {
return Err(invalid("Too many plugin package entries"));
}
std::fs::create_dir_all(staging_parent)?;
let directory = tempfile::Builder::new()
.prefix(".staging-")
.tempdir_in(staging_parent)?;
let mut names = HashSet::new();
let mut hashes = BTreeMap::new();
let mut expanded_bytes = 0_u64;
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
let name = entry.name().to_owned();
let is_dir = entry.is_dir();
let portable_name = if is_dir {
name.trim_end_matches('/')
} else {
&name
};
validate_path(portable_name)?;
if !names.insert(portable_name.to_ascii_lowercase()) {
return Err(invalid("Duplicate or case-colliding package path"));
}
if let Some(mode) = entry.unix_mode() {
let kind = mode & 0o170_000;
if kind != 0 && kind != 0o100_000 && !(is_dir && kind == 0o040_000) {
return Err(invalid(
"Plugin packages cannot contain symlinks or special files",
));
}
}
let target = directory.path().join(portable_name);
if is_dir {
std::fs::create_dir_all(&target)?;
continue;
}
expanded_bytes = expanded_bytes
.checked_add(entry.size())
.ok_or_else(|| invalid("Package size overflow"))?;
if entry.size() > MAX_FILE_BYTES || expanded_bytes > MAX_EXPANDED_BYTES {
return Err(invalid("Expanded plugin package is too large"));
}
let mut bytes = Vec::new();
let declared_size = entry.size();
entry
.by_ref()
.take(MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)?;
if bytes.len() as u64 != declared_size || bytes.len() as u64 > MAX_FILE_BYTES {
return Err(invalid("Plugin entry size mismatch"));
}
if name == "checksums.json" && bytes.len() > 256 * 1024 {
return Err(invalid("Checksums document is too large"));
}
if name != "checksums.json" {
hashes.insert(name.clone(), hex::encode(Sha256::digest(&bytes)));
}
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, bytes)?;
}
let checksums: BTreeMap<String, String> =
serde_json::from_slice(&std::fs::read(directory.path().join("checksums.json"))?)?;
if checksums != hashes {
return Err(invalid(
"Plugin package checksums do not match its complete contents",
));
}
let manifest = Manifest::parse(
&std::fs::read(directory.path().join("manifest.json"))?,
app_version,
)?;
for panel in &manifest.contributions.panels {
checked_file(directory.path(), &panel.entry)?;
}
if let Some(backend) = &manifest.backend {
for executable in backend.executables.values() {
let path = checked_file(directory.path(), executable)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))?;
}
#[cfg(not(unix))]
let _ = path;
}
}
Ok(PreparedPackage {
preview: PackagePreview {
manifest,
digest: package_digest,
expanded_bytes,
},
directory,
})
}
/// Resolve installed assets without following a symlink outside the package.
pub fn checked_file(root: &Path, relative: &str) -> Result<PathBuf> {
validate_path(relative)?;
let root = root.canonicalize()?;
let path = root.join(relative).canonicalize()?;
if !path.starts_with(&root) || !path.is_file() {
return Err(invalid(
"Plugin asset is outside its package or is not a file",
));
}
Ok(path)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::io::Write;
use zip::write::SimpleFileOptions;
pub fn package(path: &Path, extra: &[(&str, &[u8])]) {
let manifest = br#"{"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"Test tools","publisher":"Example","engine":">=1.0.0","contributions":{"panels":[{"id":"overview","title":"Overview","entry":"ui/index.html"}]}}"#;
package_manifest(path, manifest, extra);
}
pub fn package_manifest(path: &Path, manifest: &[u8], extra: &[(&str, &[u8])]) {
let mut entries = vec![
("manifest.json", manifest),
("ui/index.html", b"<p>Hello</p>".as_slice()),
];
entries.extend_from_slice(extra);
let checksums = entries
.iter()
.map(|(name, bytes)| (name.to_string(), hex::encode(Sha256::digest(bytes))))
.collect::<BTreeMap<_, _>>();
let mut zip = zip::ZipWriter::new(File::create(path).unwrap());
for (name, bytes) in entries {
zip.start_file(name, SimpleFileOptions::default()).unwrap();
zip.write_all(bytes).unwrap();
}
zip.start_file("checksums.json", SimpleFileOptions::default())
.unwrap();
zip.write_all(&serde_json::to_vec(&checksums).unwrap())
.unwrap();
zip.finish().unwrap();
}
#[test]
fn verifies_complete_package_and_rejects_traversal_and_case_collisions() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.nyap");
package(&path, &[]);
let prepared = prepare(&path, temp.path(), "1.2.12").unwrap();
assert_eq!(prepared.preview.manifest.id, "example.tools");
assert!(prepared.directory.path().join("ui/index.html").is_file());
package(&path, &[("../escape", b"bad")]);
assert!(prepare(&path, temp.path(), "1.2.12").is_err());
assert!(!temp.path().parent().unwrap().join("escape").exists());
package(&path, &[("UI/INDEX.HTML", b"bad")]);
assert!(prepare(&path, temp.path(), "1.2.12").is_err());
}
#[test]
fn rejects_modified_content() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.nyap");
package(&path, &[]);
let mut zip =
zip::ZipWriter::new_append(File::options().read(true).write(true).open(&path).unwrap())
.unwrap();
zip.start_file("unchecked.txt", SimpleFileOptions::default())
.unwrap();
zip.write_all(b"not in checksums").unwrap();
zip.finish().unwrap();
assert!(prepare(&path, temp.path(), "1.2.12").is_err());
}
}
@@ -0,0 +1,396 @@
use crate::manifest::{Manifest, valid_id};
use crate::package::{PreparedPackage, checked_file};
use crate::{Result, invalid};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledVersion {
pub manifest: Manifest,
pub digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledPlugin {
pub id: String,
pub active_version: String,
pub enabled: bool,
pub granted_permissions: Vec<String>,
pub versions: BTreeMap<String, InstalledVersion>,
}
impl InstalledPlugin {
pub fn active(&self) -> Result<&InstalledVersion> {
self.versions
.get(&self.active_version)
.ok_or_else(|| invalid("Missing active plugin version"))
}
pub fn require(&self, permission: &str) -> Result<()> {
if self.enabled
&& self
.active()?
.manifest
.permissions
.iter()
.any(|p| p == permission)
&& self.granted_permissions.iter().any(|p| p == permission)
{
Ok(())
} else {
Err(invalid(format!("Plugin permission denied: {permission}")))
}
}
}
/// Mutation is serialized by the caller, including outstanding runtime leases.
pub struct Registry {
root: PathBuf,
app_version: String,
installed: BTreeMap<String, InstalledPlugin>,
}
pub fn atomic_json(path: &Path, value: &impl Serialize) -> Result<()> {
let parent = path
.parent()
.ok_or_else(|| invalid("Missing storage directory"))?;
std::fs::create_dir_all(parent)?;
let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
temporary.write_all(&serde_json::to_vec_pretty(value)?)?;
temporary.as_file().sync_all()?;
temporary.persist(path).map_err(|e| e.error)?;
Ok(())
}
impl Registry {
pub fn open(root: PathBuf, app_version: String) -> Result<Self> {
std::fs::create_dir_all(&root)?;
let index = root.join("registry.json");
let installed: BTreeMap<String, InstalledPlugin> = if index.exists() {
if std::fs::metadata(&index)?.len() > 8 * 1024 * 1024 {
return Err(invalid("Plugin registry is too large"));
}
serde_json::from_slice(&std::fs::read(index)?)?
} else {
BTreeMap::new()
};
for (id, plugin) in &installed {
if !valid_id(id)
|| plugin.id != *id
|| plugin.versions.is_empty()
|| plugin.versions.len() > 32
{
return Err(invalid("Invalid plugin registry"));
}
plugin.active()?;
for (version, record) in &plugin.versions {
crate::manifest::validate_path(version)?;
if record.manifest.id != *id || record.manifest.version != *version {
return Err(invalid("Invalid plugin version record"));
}
}
}
Ok(Self {
root,
app_version,
installed,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn app_version(&self) -> &str {
&self.app_version
}
pub fn list(&self) -> Vec<InstalledPlugin> {
self.installed.values().cloned().collect()
}
pub fn get(&self, id: &str) -> Result<&InstalledPlugin> {
self.installed
.get(id)
.ok_or_else(|| invalid("Plugin is not installed"))
}
pub fn active_path(&self, id: &str) -> Result<PathBuf> {
let plugin = self.get(id)?;
let path = self
.root
.join(id)
.join("versions")
.join(&plugin.active_version);
let root = self.root.canonicalize()?;
let resolved = path.canonicalize()?;
if !resolved.starts_with(root) {
return Err(invalid("Plugin installation is outside its root"));
}
Ok(resolved)
}
pub fn asset(&self, id: &str, relative: &str) -> Result<PathBuf> {
checked_file(&self.active_path(id)?, relative)
}
fn save(&mut self, next: BTreeMap<String, InstalledPlugin>) -> Result<()> {
if serde_json::to_vec_pretty(&next)?.len() > 8 * 1024 * 1024 {
return Err(invalid("Plugin registry quota exceeded"));
}
atomic_json(&self.root.join("registry.json"), &next)?;
self.installed = next;
Ok(())
}
pub fn install(
&mut self,
package: PreparedPackage,
expected_digest: &str,
) -> Result<InstalledPlugin> {
if package.preview.digest != expected_digest {
return Err(invalid("Package changed since it was inspected"));
}
package.preview.manifest.validate(&self.app_version)?;
let record = InstalledVersion {
manifest: package.preview.manifest.clone(),
digest: package.preview.digest.clone(),
};
let id = record.manifest.id.clone();
let version = record.manifest.version.clone();
crate::manifest::validate_path(&version)?;
let mut next = self.installed.clone();
if next.len() >= 128 && !next.contains_key(&id) {
return Err(invalid("Too many installed plugins"));
}
let plugin = next.entry(id.clone()).or_insert_with(|| InstalledPlugin {
id: id.clone(),
active_version: version.clone(),
enabled: false,
granted_permissions: Vec::new(),
versions: BTreeMap::new(),
});
if let Ok(active) = plugin.active()
&& active.manifest.publisher != record.manifest.publisher
{
return Err(invalid(
"Plugin publisher changed; uninstall before installing from a new publisher",
));
}
let mut moved_target = None;
if let Some(existing) = plugin.versions.get(&version) {
if existing.digest != record.digest {
return Err(invalid(
"An installed version cannot be replaced by different content",
));
}
} else {
if plugin.versions.len() >= 32 {
return Err(invalid("Too many installed plugin versions"));
}
let versions_dir = self.root.join(&id).join("versions");
std::fs::create_dir_all(&versions_dir)?;
let target = versions_dir.join(&version);
if target.exists() {
return Err(invalid(
"Unregistered plugin version directory already exists",
));
}
std::fs::rename(package.directory.path(), &target)?;
moved_target = Some(target);
plugin.versions.insert(version.clone(), record);
}
plugin.active_version = version;
// New versions are always reviewed before reactivation, including permission changes.
plugin.enabled = false;
plugin.granted_permissions.retain(|p| {
plugin.versions[&plugin.active_version]
.manifest
.permissions
.contains(p)
});
let installed = plugin.clone();
if let Err(error) = self.save(next) {
if let Some(target) = moved_target {
let _ = std::fs::remove_dir_all(target);
}
return Err(error);
}
Ok(installed)
}
pub fn configure(&mut self, id: &str, enabled: bool, permissions: Vec<String>) -> Result<()> {
let mut next = self.installed.clone();
let plugin = next
.get_mut(id)
.ok_or_else(|| invalid("Plugin is not installed"))?;
let manifest = &plugin.active()?.manifest;
if enabled {
manifest.validate(&self.app_version)?;
}
let unique = permissions.iter().collect::<std::collections::HashSet<_>>();
if unique.len() != permissions.len()
|| permissions
.iter()
.any(|p| !manifest.permissions.contains(p))
{
return Err(invalid("Grant contains undeclared permissions"));
}
if enabled && manifest.backend.is_some() && !permissions.iter().any(|p| p == "native") {
return Err(invalid(
"Native backend requires explicit native execution approval",
));
}
plugin.enabled = enabled;
plugin.granted_permissions = permissions;
self.save(next)
}
pub fn activate_version(&mut self, id: &str, version: &str) -> Result<()> {
let mut next = self.installed.clone();
let plugin = next
.get_mut(id)
.ok_or_else(|| invalid("Plugin is not installed"))?;
let record = plugin
.versions
.get(version)
.ok_or_else(|| invalid("Plugin version is not installed"))?;
record.manifest.validate(&self.app_version)?;
plugin.active_version = version.to_string();
plugin.enabled = false;
plugin
.granted_permissions
.retain(|p| record.manifest.permissions.contains(p));
self.save(next)
}
pub fn uninstall(&mut self, id: &str) -> Result<()> {
self.get(id)?;
let mut next = self.installed.clone();
next.remove(id);
// Rename before committing, so a reinstall cannot observe stale version directories.
let directory = self.root.join(id);
let trash = self.root.join(format!(".removed-{}", uuid::Uuid::new_v4()));
if directory.exists() {
std::fs::rename(&directory, &trash)?;
}
if let Err(error) = self.save(next) {
if trash.exists() {
let _ = std::fs::rename(&trash, &directory);
}
return Err(error);
}
if trash.exists() {
std::fs::remove_dir_all(trash)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::package::{prepare, tests::package};
#[test]
fn updates_rollback_and_revocation_require_fresh_activation() {
use crate::package::tests::package_manifest;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("plugins");
let path = temp.path().join("test.nyap");
let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap();
let mut manifest = serde_json::json!({"manifestVersion":1,"id":"example.tools","name":"Tools","version":"1.0.0","description":"test","publisher":"Example","engine":">=1.0.0","permissions":["session.read","storage"]});
let install = |registry: &mut Registry, manifest: &serde_json::Value| {
package_manifest(&path, &serde_json::to_vec(manifest).unwrap(), &[]);
let prepared = prepare(&path, &root, "1.2.12").unwrap();
let digest = prepared.preview.digest.clone();
registry.install(prepared, &digest)
};
install(&mut registry, &manifest).unwrap();
registry
.configure(
"example.tools",
true,
vec!["session.read".into(), "storage".into()],
)
.unwrap();
assert!(
registry
.get("example.tools")
.unwrap()
.require("storage")
.is_ok()
);
registry
.configure("example.tools", true, vec!["session.read".into()])
.unwrap();
assert!(
registry
.get("example.tools")
.unwrap()
.require("storage")
.is_err()
);
manifest["version"] = serde_json::json!("1.1.0");
manifest["permissions"] = serde_json::json!(["storage"]);
let update = install(&mut registry, &manifest).unwrap();
assert!(!update.enabled);
assert!(update.granted_permissions.is_empty());
assert_eq!(update.versions.len(), 2);
registry
.configure("example.tools", true, vec!["storage".into()])
.unwrap();
registry.activate_version("example.tools", "1.0.0").unwrap();
let rollback = registry.get("example.tools").unwrap();
assert!(!rollback.enabled);
assert!(rollback.require("storage").is_err());
manifest["version"] = serde_json::json!("1.2.0");
manifest["publisher"] = serde_json::json!("Imposter");
assert!(install(&mut registry, &manifest).is_err());
assert_eq!(
registry.get("example.tools").unwrap().active_version,
"1.0.0"
);
let restored = Registry::open(root, "1.2.12".into()).unwrap();
assert!(!restored.get("example.tools").unwrap().enabled);
}
#[test]
fn installation_is_disabled_until_granted_and_survives_restart() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.nyap");
package(&path, &[]);
let root = temp.path().join("plugins");
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();
assert!(!registry.install(prepared, &digest).unwrap().enabled);
assert!(
registry
.configure("example.tools", true, vec!["native".into()])
.is_err()
);
registry.configure("example.tools", true, vec![]).unwrap();
let mut restored = Registry::open(root, "1.2.12".into()).unwrap();
assert!(restored.get("example.tools").unwrap().enabled);
assert!(restored.asset("example.tools", "../registry.json").is_err());
restored.uninstall("example.tools").unwrap();
assert!(restored.list().is_empty());
}
#[test]
fn inspection_digest_and_immutable_version_prevent_package_swaps() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("test.nyap");
package(&path, &[]);
let root = temp.path().join("plugins");
let mut registry = Registry::open(root.clone(), "1.2.12".into()).unwrap();
let prepared = prepare(&path, &root, "1.2.12").unwrap();
assert!(registry.install(prepared, "wrong digest").is_err());
let prepared = prepare(&path, &root, "1.2.12").unwrap();
let digest = prepared.preview.digest.clone();
registry.install(prepared, &digest).unwrap();
package(&path, &[("extra.txt", b"new content")]);
let prepared = prepare(&path, &root, "1.2.12").unwrap();
let digest = prepared.preview.digest.clone();
assert!(registry.install(prepared, &digest).is_err());
}
}
@@ -0,0 +1,599 @@
use crate::manifest::{API_VERSION, Manifest, Transport};
use crate::package::checked_file;
use crate::{Error, Result, invalid};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
use tokio::sync::{Mutex, Semaphore, broadcast, mpsc, oneshot, watch};
use tokio_util::sync::CancellationToken;
pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
const MAX_JSON_BYTES: usize = 2 * 1024 * 1024;
const MAX_PENDING: usize = 64;
#[derive(Debug, Clone)]
pub enum BackendEvent {
Notification { method: String, params: Value },
Binary { channel: String, data: Vec<u8> },
Stderr(String),
ProtocolError(String),
Stopped(String),
}
#[async_trait::async_trait]
pub trait HostHandler: Send + Sync + 'static {
async fn call(&self, method: &str, params: Value) -> Result<Value>;
}
struct Shared {
host_calls: Mutex<HashMap<String, CancellationToken>>,
pending: Mutex<HashMap<u64, oneshot::Sender<Result<Value>>>>,
next_id: AtomicU64,
running: AtomicBool,
stop: watch::Sender<bool>,
writer: mpsc::Sender<WireFrame>,
events: broadcast::Sender<BackendEvent>,
observer: Option<Arc<dyn Fn(BackendEvent) + Send + Sync>>,
}
impl Shared {
fn publish(&self, event: BackendEvent) {
if let Some(observer) = &self.observer {
observer(event.clone());
}
let _ = self.events.send(event);
}
}
pub struct Sidecar {
shared: Arc<Shared>,
transport: Transport,
}
enum WireFrame {
Json(Value),
Binary(String, Vec<u8>),
}
impl Drop for Sidecar {
fn drop(&mut self) {
let _ = self.shared.stop.send(true);
}
}
impl Sidecar {
pub async fn spawn(
root: &Path,
manifest: &Manifest,
app_version: &str,
granted_permissions: &[String],
handler: Arc<dyn HostHandler>,
) -> Result<Self> {
Self::spawn_observed(
root,
manifest,
app_version,
granted_permissions,
handler,
None,
)
.await
}
pub async fn spawn_observed(
root: &Path,
manifest: &Manifest,
app_version: &str,
granted_permissions: &[String],
handler: Arc<dyn HostHandler>,
observer: Option<Arc<dyn Fn(BackendEvent) + Send + Sync>>,
) -> Result<Self> {
let backend = manifest
.backend
.as_ref()
.ok_or_else(|| invalid("Plugin has no native backend"))?;
if !granted_permissions.iter().any(|p| p == "native") {
return Err(invalid("Native execution has not been approved"));
}
let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH);
let relative = backend
.executables
.get(&platform)
.ok_or_else(|| invalid(format!("Plugin backend is unavailable for {platform}")))?;
let executable = checked_file(root, relative)?;
let mut command = tokio::process::Command::new(executable);
command
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
// Avoid handing ambient AI/MCP/cloud credentials to native backends.
command.env_clear();
for key in [
"PATH",
"SystemRoot",
"WINDIR",
"TEMP",
"TMP",
"LANG",
"LC_ALL",
] {
if let Some(value) = std::env::var_os(key) {
command.env(key, value);
}
}
command
.env("NYATERM_PLUGIN_ID", &manifest.id)
.env("NYATERM_PLUGIN_VERSION", &manifest.version)
.env("NYATERM_APP_VERSION", app_version)
.env(
"NYATERM_PLUGIN_TRANSPORT",
match backend.transport {
Transport::StdioJsonl => "stdio-jsonl",
Transport::StdioFramed => "stdio-framed",
},
);
#[cfg(windows)]
command.creation_flags(0x0800_0000);
let mut child = command.spawn()?;
let stdin = child
.stdin
.take()
.ok_or_else(|| invalid("Missing plugin stdin"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| invalid("Missing plugin stdout"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| invalid("Missing plugin stderr"))?;
let (writer, receiver) = mpsc::channel(8);
let (events, _) = broadcast::channel(64);
let (stop, mut stop_receiver) = watch::channel(false);
let shared = Arc::new(Shared {
host_calls: Mutex::new(HashMap::new()),
pending: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
running: AtomicBool::new(true),
stop,
writer,
events,
observer,
});
let transport = backend.transport;
let writer_shared = shared.clone();
tokio::spawn(async move {
let mut stop = writer_shared.stop.subscribe();
tokio::select! {
_ = wait_stop(&mut stop) => {},
result = writer_loop(stdin, receiver, transport) => {
if let Err(error) = result { writer_shared.publish(BackendEvent::ProtocolError(error.to_string())); }
}
}
let _ = writer_shared.stop.send(true);
});
let reader_shared = shared.clone();
tokio::spawn(async move {
let mut stop = reader_shared.stop.subscribe();
tokio::select! {
_ = wait_stop(&mut stop) => {},
result = reader_loop(stdout, transport, reader_shared.clone(), handler) => {
if let Err(error) = result { reader_shared.publish(BackendEvent::ProtocolError(error.to_string())); }
}
}
let _ = reader_shared.stop.send(true);
});
let stderr_shared = shared.clone();
tokio::spawn(async move {
let mut stderr = stderr;
let mut buffer = [0_u8; 4096];
let mut stop = stderr_shared.stop.subscribe();
let mut reported = 0_usize;
let mut stopping = false;
let mut deadline = tokio::time::Instant::now();
loop {
// Preserve final stderr after a protocol failure/process exit, with a
// deadline in case a descendant keeps the pipe open.
let result = tokio::select! {
_ = wait_stop(&mut stop), if !stopping => {
stopping = true;
deadline = tokio::time::Instant::now() + Duration::from_secs(1);
continue;
}
_ = tokio::time::sleep_until(deadline), if stopping => break,
result = stderr.read(&mut buffer) => result,
};
match result {
Ok(0) | Err(_) => break,
Ok(read) => {
// Drain all stderr, but bound log retention and event volume per activation.
if reported < 64 * 1024 {
stderr_shared.publish(BackendEvent::Stderr(
String::from_utf8_lossy(&buffer[..read]).into_owned(),
));
reported += read;
}
}
}
}
});
let process_shared = shared.clone();
tokio::spawn(async move {
let status = tokio::select! {
result = child.wait() => result.map(|s| s.to_string()).unwrap_or_else(|e| e.to_string()),
_ = wait_stop(&mut stop_receiver) => {
let _ = child.start_kill();
let _ = child.wait().await;
"Plugin stopped".to_string()
}
};
process_shared.running.store(false, Ordering::Release);
let _ = process_shared.stop.send(true);
for (_, pending) in process_shared.pending.lock().await.drain() {
let _ = pending.send(Err(Error::Runtime(format!(
"Plugin process closed: {status}"
))));
}
process_shared.publish(BackendEvent::Stopped(status));
});
let sidecar = Self { shared, transport };
let response = sidecar
.request(
"plugin/initialize",
json!({
"pluginId": manifest.id, "pluginVersion": manifest.version,
"appVersion": app_version, "apiVersion": API_VERSION,
"protocolVersion": 1, "permissions": granted_permissions,
}),
Duration::from_secs(10),
)
.await?;
if response.get("pluginId").and_then(Value::as_str) != Some(manifest.id.as_str())
|| response.get("pluginVersion").and_then(Value::as_str)
!= Some(manifest.version.as_str())
|| response.get("protocolVersion").and_then(Value::as_u64) != Some(1)
{
return Err(invalid(
"Plugin initialization handshake does not match its manifest",
));
}
Ok(sidecar)
}
pub fn is_running(&self) -> bool {
self.shared.running.load(Ordering::Acquire) && !*self.shared.stop.borrow()
}
pub fn subscribe(&self) -> broadcast::Receiver<BackendEvent> {
self.shared.events.subscribe()
}
pub fn stop(&self) {
let _ = self.shared.stop.send(true);
}
pub async fn request(&self, method: &str, params: Value, timeout: Duration) -> Result<Value> {
if !self.is_running() {
return Err(Error::Runtime("Plugin process is not running".into()));
}
let id = self.shared.next_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
{
let mut pending = self.shared.pending.lock().await;
if pending.len() >= MAX_PENDING {
return Err(Error::Runtime("Too many pending plugin requests".into()));
}
pending.insert(id, sender);
}
let shared = self.shared.clone();
// The guard also removes abandoned requests when the caller is cancelled.
struct PendingGuard {
shared: Arc<Shared>,
id: u64,
}
impl Drop for PendingGuard {
fn drop(&mut self) {
let shared = self.shared.clone();
let id = self.id;
tokio::spawn(async move {
if shared.pending.lock().await.remove(&id).is_some() {
let _ = shared.writer.try_send(WireFrame::Json(
json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":id}}),
));
}
});
}
}
let _guard = PendingGuard {
shared: shared.clone(),
id,
};
let operation = async {
shared
.writer
.send(WireFrame::Json(
json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}),
))
.await
.map_err(|_| Error::Runtime("Plugin writer closed".into()))?;
receiver
.await
.map_err(|_| Error::Runtime("Plugin response channel closed".into()))?
};
match tokio::time::timeout(timeout, operation).await {
Ok(result) => result,
Err(_) => {
shared.pending.lock().await.remove(&id);
let _ = shared.writer.try_send(WireFrame::Json(
json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":id}}),
));
Err(Error::Runtime("Plugin request timed out".into()))
}
}
}
pub async fn send_binary(&self, channel: String, data: Vec<u8>) -> Result<()> {
if self.transport != Transport::StdioFramed {
return Err(invalid("Binary frames require stdio-framed"));
}
if channel.is_empty()
|| channel.len() > 128
|| data.len() + channel.len() + 2 > MAX_FRAME_BYTES
{
return Err(invalid("Invalid binary plugin frame"));
}
if !self.is_running() {
return Err(Error::Runtime("Plugin process is not running".into()));
}
tokio::time::timeout(
Duration::from_secs(10),
self.shared.writer.send(WireFrame::Binary(channel, data)),
)
.await
.map_err(|_| Error::Runtime("Plugin binary writer timed out".into()))?
.map_err(|_| Error::Runtime("Plugin writer closed".into()))
}
}
async fn writer_loop(
mut writer: impl AsyncWrite + Unpin,
mut receiver: mpsc::Receiver<WireFrame>,
transport: Transport,
) -> Result<()> {
while let Some(frame) = receiver.recv().await {
let (kind, payload) = match frame {
WireFrame::Json(value) => {
let bytes = serde_json::to_vec(&value)?;
if bytes.len() > MAX_JSON_BYTES {
return Err(invalid("Plugin JSON frame is too large"));
}
(1_u8, bytes)
}
WireFrame::Binary(channel, data) => {
if transport != Transport::StdioFramed {
return Err(invalid("Binary frames require stdio-framed"));
}
let mut payload = Vec::with_capacity(2 + channel.len() + data.len());
payload.extend_from_slice(&(channel.len() as u16).to_le_bytes());
payload.extend_from_slice(channel.as_bytes());
payload.extend_from_slice(&data);
(2, payload)
}
};
if transport == Transport::StdioFramed {
writer.write_u8(kind).await?;
writer.write_u32_le(payload.len() as u32).await?;
}
writer.write_all(&payload).await?;
if transport == Transport::StdioJsonl {
writer.write_u8(b'\n').await?;
}
writer.flush().await?;
}
Ok(())
}
async fn read_frame(
reader: &mut (impl AsyncRead + Unpin),
transport: Transport,
) -> Result<WireFrame> {
if transport == Transport::StdioFramed {
let kind = reader.read_u8().await?;
let length = reader.read_u32_le().await? as usize;
if length > MAX_FRAME_BYTES || (kind == 1 && length > MAX_JSON_BYTES) {
return Err(invalid("Plugin frame is too large"));
}
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).await?;
return match kind {
1 => Ok(WireFrame::Json(serde_json::from_slice(&bytes)?)),
2 if bytes.len() >= 2 => {
let channel_len = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
if channel_len == 0 || channel_len > 128 || channel_len + 2 > bytes.len() {
return Err(invalid("Invalid binary channel"));
}
let channel = std::str::from_utf8(&bytes[2..2 + channel_len])
.map_err(|_| invalid("Invalid binary channel encoding"))?
.to_string();
Ok(WireFrame::Binary(
channel,
bytes[2 + channel_len..].to_vec(),
))
}
_ => Err(invalid("Invalid plugin frame kind")),
};
}
// A capped byte loop over BufReader avoids allocating unbounded JSONL lines.
let mut bytes = Vec::new();
loop {
let byte = reader.read_u8().await?;
if byte == b'\n' {
break;
}
if bytes.len() >= MAX_JSON_BYTES {
return Err(invalid("Plugin JSON line is too large"));
}
bytes.push(byte);
}
Ok(WireFrame::Json(serde_json::from_slice(&bytes)?))
}
async fn reader_loop(
reader: impl AsyncRead + Unpin,
transport: Transport,
shared: Arc<Shared>,
handler: Arc<dyn HostHandler>,
) -> Result<()> {
let mut reader = BufReader::new(reader);
let host_slots = Arc::new(Semaphore::new(16));
loop {
match read_frame(&mut reader, transport).await? {
WireFrame::Binary(channel, data) => {
shared.publish(BackendEvent::Binary { channel, data });
}
WireFrame::Json(value) => {
if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Err(invalid("Invalid plugin JSON-RPC version"));
}
if let Some(method) = value.get("method").and_then(Value::as_str) {
if method == "$/cancelRequest" {
if let Some(id) = value.pointer("/params/id").and_then(Value::as_str)
&& let Some(token) = shared.host_calls.lock().await.get(id)
{
token.cancel();
}
continue;
}
if let Some(id) = value.get("id") {
let id = id
.as_str()
.filter(|s| !s.is_empty() && s.len() <= 128)
.ok_or_else(|| invalid("Plugin-owned request IDs must be strings"))?
.to_string();
let permit = host_slots
.clone()
.try_acquire_owned()
.map_err(|_| invalid("Too many concurrent host requests"))?;
let method = method.to_string();
let params = value.get("params").cloned().unwrap_or(Value::Null);
let handler = handler.clone();
let shared = shared.clone();
let cancellation = CancellationToken::new();
{
let mut calls = shared.host_calls.lock().await;
if calls.len() >= 16 || calls.contains_key(&id) {
return Err(invalid("Too many host calls or duplicate ID"));
}
calls.insert(id.clone(), cancellation.clone());
}
tokio::spawn(async move {
let _permit = permit;
let mut stop = shared.stop.subscribe();
let result = tokio::select! {
_ = wait_stop(&mut stop) => None,
_ = cancellation.cancelled() => None,
result = tokio::time::timeout(Duration::from_secs(180), handler.call(&method, params)) => Some(result.unwrap_or_else(|_| Err(Error::Runtime("Host request timed out".into())))),
};
shared.host_calls.lock().await.remove(&id);
let Some(result) = result else {
return;
};
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
Err(error) => {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32000,"message":error.to_string()}})
}
};
let _ = shared.writer.send(WireFrame::Json(response)).await;
});
} else if method.starts_with("event/") && method.len() <= 128 {
shared.publish(BackendEvent::Notification {
method: method.to_string(),
params: value.get("params").cloned().unwrap_or(Value::Null),
});
}
} else {
let id = value
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| invalid("Host-owned response IDs must be numbers"))?;
if let Some(sender) = shared.pending.lock().await.remove(&id) {
let result = if let Some(error) = value.get("error") {
Err(Error::Runtime(
error
.get("message")
.and_then(Value::as_str)
.unwrap_or("Plugin RPC failed")
.to_string(),
))
} else {
value
.get("result")
.cloned()
.ok_or_else(|| invalid("Plugin response has no result"))
};
let _ = sender.send(result);
}
}
}
}
}
}
async fn wait_stop(receiver: &mut watch::Receiver<bool>) {
loop {
if *receiver.borrow_and_update() {
return;
}
if receiver.changed().await.is_err() {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn framed_transport_roundtrips_json_and_binary_without_base64() {
let (writer, mut reader) = tokio::io::duplex(1024);
let (send, recv) = mpsc::channel(2);
let task = tokio::spawn(writer_loop(writer, recv, Transport::StdioFramed));
send.send(WireFrame::Json(
json!({"jsonrpc":"2.0","id":1,"result":true}),
))
.await
.unwrap();
send.send(WireFrame::Binary("stdout/s1".into(), vec![0, 255, 10]))
.await
.unwrap();
drop(send);
assert!(
matches!(read_frame(&mut reader, Transport::StdioFramed).await.unwrap(), WireFrame::Json(v) if v["result"] == true)
);
assert!(
matches!(read_frame(&mut reader, Transport::StdioFramed).await.unwrap(), WireFrame::Binary(c, d) if c == "stdout/s1" && d == [0, 255, 10])
);
task.await.unwrap().unwrap();
}
#[tokio::test]
async fn rejects_oversized_frame_before_allocating_payload() {
let bytes = [1, 255, 255, 255, 127];
assert!(
read_frame(&mut bytes.as_slice(), Transport::StdioFramed)
.await
.is_err()
);
let bytes = [2, 3, 0, 0, 0, 100, 0, 0];
assert!(
read_frame(&mut bytes.as_slice(), Transport::StdioFramed)
.await
.is_err()
);
}
}
@@ -0,0 +1,215 @@
#![cfg(feature = "sdk-fixture")]
use nyaterm_plugin_runtime::{
Result,
diagnostics::{Diagnostics, Status},
manifest::Manifest,
sidecar::{BackendEvent, HostHandler, Sidecar},
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Default)]
struct Host {
completed: std::sync::atomic::AtomicUsize,
}
#[async_trait::async_trait]
impl HostHandler for Host {
async fn call(&self, method: &str, params: Value) -> Result<Value> {
assert_eq!(params["scopeToken"], "fixture-scope");
if method == "host/slow" {
tokio::time::sleep(Duration::from_millis(100)).await;
self.completed
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
Ok(json!({"name":"selected-session"}))
}
}
fn fixture(transport: &str, id: &str) -> (tempfile::TempDir, Manifest) {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("bin")).unwrap();
let executable = if cfg!(windows) {
"bin/sdk.exe"
} else {
"bin/sdk"
};
std::fs::copy(
env!("CARGO_BIN_EXE_sdk-fixture"),
root.path().join(executable),
)
.unwrap();
let manifest=Manifest::parse(&serde_json::to_vec(&json!({"manifestVersion":1,"id":id,"name":"SDK fixture","version":"1.0.0","description":"SDK test","publisher":"Example","engine":">=1.0.0","permissions":["native"],"backend":{"transport":transport,"executables":{format!("{}-{}",std::env::consts::OS,std::env::consts::ARCH):executable}}})).unwrap(),"1.2.12").unwrap();
(root, manifest)
}
async fn call(sidecar: &Sidecar, method: &str, input: Value) -> Result<Value> {
sidecar
.request(
method,
json!({"scopeToken":"fixture-scope","input":input}),
Duration::from_secs(5),
)
.await
}
async fn exercise(transport: &str) {
let (root, manifest) = fixture(transport, "example.sdk");
let events = Arc::new(Mutex::new(Vec::new()));
let observed = events.clone();
let host = Arc::new(Host::default());
let sidecar = Sidecar::spawn_observed(
root.path(),
&manifest,
"1.2.12",
&["native".into()],
host.clone(),
Some(Arc::new(move |event| {
observed.lock().unwrap().push(event);
})),
)
.await
.unwrap();
assert_eq!(
call(&sidecar, "ui/count", Value::Null).await.unwrap(),
json!(1)
);
assert_eq!(
call(&sidecar, "ui/count", Value::Null).await.unwrap(),
json!(2)
);
assert_eq!(
call(&sidecar, "ui/host", Value::Null).await.unwrap()["name"],
"selected-session"
);
let (host_output, echo) = tokio::join!(
call(&sidecar, "ui/host", Value::Null),
call(&sidecar, "ui/echo", json!("concurrent"))
);
assert_eq!(host_output.unwrap()["name"], "selected-session");
assert_eq!(echo.unwrap(), json!("concurrent"));
call(&sidecar, "ui/save-context", Value::Null)
.await
.unwrap();
assert!(
call(&sidecar, "ui/reuse-context", Value::Null)
.await
.unwrap_err()
.to_string()
.contains("cancelled")
);
assert!(
call(&sidecar, "ui/unknown", Value::Null)
.await
.unwrap_err()
.to_string()
.contains("Unknown method")
);
assert!(
call(&sidecar, "ui/host-timeout", Value::Null)
.await
.unwrap_err()
.to_string()
.contains("timed out")
);
assert!(
call(&sidecar, "ui/panic", Value::Null)
.await
.unwrap_err()
.to_string()
.contains("panicked")
);
call(&sidecar, "ui/log", Value::Null).await.unwrap();
assert!(
sidecar
.request(
"ui/slow",
json!({"scopeToken":"fixture-scope"}),
Duration::from_millis(20)
)
.await
.is_err()
);
tokio::time::sleep(Duration::from_millis(1100)).await;
assert_eq!(
call(&sidecar, "ui/completed", Value::Null).await.unwrap(),
json!(0)
);
assert_eq!(host.completed.load(std::sync::atomic::Ordering::Relaxed), 0);
// Cancelled reverse calls and handlers cannot finish work or corrupt new calls.
assert_eq!(
call(&sidecar, "ui/echo", json!("alive")).await.unwrap(),
json!("alive")
);
if transport == "stdio-framed" {
sidecar
.send_binary("fixture".into(), vec![3, 2, 1])
.await
.unwrap();
for _ in 0..20 {
if call(&sidecar, "ui/binary", Value::Null).await.unwrap() == json!(1) {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(events.lock().unwrap().iter().any(|e|matches!(e,BackendEvent::Binary{channel,data} if channel=="fixture" && data==&[1,2,3])));
}
assert!(
events
.lock()
.unwrap()
.iter()
.any(|e| matches!(e,BackendEvent::Notification{method,..} if method=="event/log"))
);
assert!(
events.lock().unwrap().iter().any(
|e| matches!(e,BackendEvent::Stderr(message) if message.contains("initialization"))
)
);
assert!(call(&sidecar, "ui/crash", Value::Null).await.is_err());
assert!(!sidecar.is_running());
}
#[tokio::test]
async fn sdk_jsonl_interoperates() {
exercise("stdio-jsonl").await;
}
#[tokio::test]
async fn sdk_framed_interoperates() {
exercise("stdio-framed").await;
}
#[tokio::test]
async fn observes_startup_errors_before_handshake_completes() {
let (root, manifest) = fixture("stdio-jsonl", "bad.sdk");
let store = Diagnostics::default();
let ticket = store.begin("bad.sdk", "1.0.0");
let observer = store.clone();
let event_ticket = ticket.clone();
let result = Sidecar::spawn_observed(
root.path(),
&manifest,
"1.2.12",
&["native".into()],
Arc::new(Host::default()),
Some(Arc::new(move |event| {
if let BackendEvent::Stderr(message) | BackendEvent::ProtocolError(message) = event {
observer.update(&event_ticket, None, "error", "startup", &message);
}
})),
)
.await;
assert!(result.is_err());
store.update(
&ticket,
Some(Status::Error),
"error",
"host",
"Handshake failed",
);
tokio::time::sleep(Duration::from_millis(100)).await;
let snapshot = store.snapshot("bad.sdk", "1.0.0");
assert_eq!(snapshot.status, Status::Error);
assert!(
snapshot
.logs
.iter()
.any(|entry| entry.message.contains("SDK initialization started"))
);
}
@@ -0,0 +1,117 @@
use nyaterm_plugin_runtime::{
Result,
manifest::Manifest,
sidecar::{HostHandler, Sidecar},
};
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
struct Host;
#[async_trait::async_trait]
impl HostHandler for Host {
async fn call(&self, method: &str, params: Value) -> Result<Value> {
assert_eq!(method, "host/session");
assert_eq!(params["scopeToken"], "fixture-scope");
Ok(json!({"name":"selected-session"}))
}
}
fn fixture(id: &str) -> (tempfile::TempDir, Manifest) {
let root = tempfile::tempdir().unwrap();
std::fs::create_dir(root.path().join("bin")).unwrap();
let executable = if cfg!(windows) {
"bin/fixture.exe"
} else {
"bin/fixture"
};
std::fs::copy(
env!("CARGO_BIN_EXE_plugin-fixture"),
root.path().join(executable),
)
.unwrap();
let manifest = Manifest::parse(&serde_json::to_vec(&json!({
"manifestVersion":1,"id":id,"name":"Fixture","version":"1.0.0","description":"Native fixture","publisher":"Example","engine":">=1.0.0",
"permissions":["native"],"backend":{"transport":"stdio-jsonl","executables":{format!("{}-{}",std::env::consts::OS,std::env::consts::ARCH):executable}},
})).unwrap(), "1.2.12").unwrap();
(root, manifest)
}
#[tokio::test]
async fn reuses_process_supports_reverse_rpc_and_recovers_after_request_timeout() {
let (root, manifest) = fixture("example.fixture");
assert!(
Sidecar::spawn(root.path(), &manifest, "1.2.12", &[], Arc::new(Host))
.await
.is_err()
);
let sidecar = Sidecar::spawn(
root.path(),
&manifest,
"1.2.12",
&["native".into()],
Arc::new(Host),
)
.await
.unwrap();
let output = sidecar
.request("ui/echo", json!({"hello":true}), Duration::from_secs(5))
.await
.unwrap();
assert_eq!(output, json!({"hello":true}));
assert_eq!(
sidecar
.request("ui/host", Value::Null, Duration::from_secs(5))
.await
.unwrap()["name"],
"selected-session"
);
assert!(
sidecar
.request("ui/timeout", Value::Null, Duration::from_millis(30))
.await
.is_err()
);
assert_eq!(
sidecar
.request("ui/echo", json!("still alive"), Duration::from_secs(5))
.await
.unwrap(),
json!("still alive")
);
sidecar.stop();
assert!(!sidecar.is_running());
}
#[tokio::test]
async fn rejects_identity_mismatch_and_resolves_calls_on_process_crash() {
let (root, manifest) = fixture("bad.handshake");
assert!(
Sidecar::spawn(
root.path(),
&manifest,
"1.2.12",
&["native".into()],
Arc::new(Host)
)
.await
.is_err()
);
let (root, manifest) = fixture("example.fixture");
let sidecar = Sidecar::spawn(
root.path(),
&manifest,
"1.2.12",
&["native".into()],
Arc::new(Host),
)
.await
.unwrap();
assert!(
sidecar
.request("ui/crash", Value::Null, Duration::from_secs(5))
.await
.is_err()
);
assert!(!sidecar.is_running());
}
+12
View File
@@ -425,6 +425,18 @@ pub fn quit_application(app: &tauri::AppHandle) {
}
pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
if matches!(event, tauri::WindowEvent::Destroyed) {
if let Some(manager) = window
.app_handle()
.try_state::<Arc<crate::core::plugins::PluginManager>>()
{
let manager = manager.inner().clone();
let label = window.label().to_string();
tauri::async_runtime::spawn(async move {
manager.revoke_window(&label).await;
});
}
}
if crate::window_state::is_main_window_label(window.label()) {
match event {
tauri::WindowEvent::Moved(_)
+6
View File
@@ -407,6 +407,12 @@ pub fn set_app_lock_state(
AppLockStateChangedPayload { locked },
);
if locked {
if let Some(manager) = app.try_state::<Arc<crate::core::plugins::PluginManager>>() {
let manager = manager.inner().clone();
tauri::async_runtime::spawn(async move {
manager.revoke_all().await;
});
}
if let Some(manager) = app.try_state::<Arc<crate::core::mcp::McpManager>>() {
let manager = manager.inner().clone();
tauri::async_runtime::spawn(async move {
+1
View File
@@ -17,6 +17,7 @@ pub mod macos_menu;
pub mod mcp;
pub mod note;
pub mod otp;
pub mod plugins;
pub mod process;
pub mod proxy;
pub mod rdp;
+162
View File
@@ -0,0 +1,162 @@
use crate::core::plugins::{PluginManager, PluginScope};
use crate::error::AppResult;
use nyaterm_plugin_runtime::package::PackagePreview;
use nyaterm_plugin_runtime::registry::InstalledPlugin;
use serde_json::Value;
use std::path::PathBuf;
use std::sync::Arc;
#[tauri::command]
pub async fn list_plugins(
manager: tauri::State<'_, Arc<PluginManager>>,
) -> AppResult<Vec<InstalledPlugin>> {
manager.list().await
}
#[tauri::command]
pub async fn inspect_plugin_package(
manager: tauri::State<'_, Arc<PluginManager>>,
path: String,
) -> AppResult<PackagePreview> {
manager.inspect(PathBuf::from(path)).await
}
#[tauri::command]
pub async fn install_plugin_package(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
path: String,
digest: String,
) -> AppResult<InstalledPlugin> {
manager.install(&app, PathBuf::from(path), digest).await
}
#[tauri::command]
pub async fn configure_plugin(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
expected_version: String,
enabled: bool,
granted_permissions: Vec<String>,
) -> AppResult<()> {
manager
.configure(
&app,
&plugin_id,
&expected_version,
enabled,
granted_permissions,
)
.await
}
#[tauri::command]
pub async fn activate_plugin_version(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
version: String,
) -> AppResult<()> {
manager.activate_version(&app, &plugin_id, &version).await
}
#[tauri::command]
pub async fn uninstall_plugin(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
) -> AppResult<()> {
manager.uninstall(&app, &plugin_id).await
}
#[tauri::command]
pub async fn create_plugin_scope(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
session_id: Option<String>,
) -> AppResult<PluginScope> {
manager
.create_scope(&app, window.label(), &plugin_id, session_id)
.await
}
#[tauri::command]
pub async fn close_plugin_scope(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
) -> AppResult<()> {
manager.close_scope(window.label(), &token).await
}
#[tauri::command]
pub async fn plugin_host_call(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
method: String,
params: Value,
) -> AppResult<Value> {
manager
.inner()
.host_call(&app, Some(window.label()), &token, &method, params)
.await
}
#[tauri::command]
pub async fn plugin_backend_call(
app: tauri::AppHandle,
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
token: String,
method: String,
params: Value,
) -> AppResult<Value> {
manager
.inner()
.backend_call(&app, window.label(), &token, &method, params)
.await
}
#[tauri::command]
pub async fn respond_plugin_approval(
window: tauri::WebviewWindow,
manager: tauri::State<'_, Arc<PluginManager>>,
request_id: String,
approved: bool,
) -> AppResult<()> {
manager
.respond_approval(window.label(), &request_id, approved)
.await
}
#[tauri::command]
pub async fn get_plugin_diagnostics(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
) -> AppResult<nyaterm_plugin_runtime::diagnostics::Snapshot> {
manager.diagnostic_snapshot(&app, &plugin_id).await
}
#[tauri::command]
pub async fn clear_plugin_logs(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
) -> AppResult<()> {
manager.clear_logs(&app, &plugin_id).await
}
#[tauri::command]
pub async fn stop_plugin_backend(
app: tauri::AppHandle,
manager: tauri::State<'_, Arc<PluginManager>>,
plugin_id: String,
) -> AppResult<()> {
manager.stop_backend(&app, &plugin_id).await
}
+5 -1
View File
@@ -214,7 +214,11 @@ fn default_left_top() -> Vec<String> {
}
fn default_left_bottom() -> Vec<String> {
vec!["syncBackupHistory".to_string(), "settings".to_string()]
vec![
"syncBackupHistory".to_string(),
"plugins".to_string(),
"settings".to_string(),
]
}
fn default_right_top() -> Vec<String> {
+1
View File
@@ -16,6 +16,7 @@ pub mod monitoring;
pub mod network;
pub mod note_export;
mod output;
pub mod plugins;
pub mod portable_snapshot;
mod quick_commands;
pub mod rdp;
+372
View File
@@ -0,0 +1,372 @@
use super::{Approval, PluginManager, Scope, plugin_error, require_session};
use crate::core::SessionManager;
use crate::core::capabilities::{
TerminalExecuteRequest, assess_command_risk, execute_terminal_command,
};
use crate::error::{AppError, AppResult};
use serde::Serialize;
use serde_json::{Value, json};
use std::collections::BTreeMap;
use std::sync::Arc;
use std::time::Duration;
use tauri::{Emitter, Manager};
use tokio::io::AsyncReadExt;
use tokio::sync::oneshot;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct ApprovalRequest {
request_id: String,
plugin_id: String,
plugin_name: String,
capability: String,
session_name: String,
summary: String,
risk: crate::config::RiskLevel,
}
struct ApprovalGuard {
manager: std::sync::Weak<PluginManager>,
window: tauri::WebviewWindow,
id: String,
}
impl Drop for ApprovalGuard {
fn drop(&mut self) {
let manager = self.manager.clone();
let id = self.id.clone();
let window = self.window.clone();
tauri::async_runtime::spawn(async move {
if let Some(manager) = manager.upgrade() {
manager.approvals.lock().await.remove(&id);
}
let _ = window.emit("plugin-approval-closed", &id);
});
}
}
impl PluginManager {
pub async fn host_call(
self: &Arc<Self>,
app: &tauri::AppHandle,
caller_window: Option<&str>,
token: &str,
method: &str,
params: Value,
) -> AppResult<Value> {
if serde_json::to_vec(&params)?.len() > 1024 * 1024 {
return Err(AppError::Config("Plugin request is too large".into()));
}
let scope = self.scope(app, token, caller_window).await?;
let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?;
self.scope(app, token, caller_window).await?;
let plugin = self.plugin(&scope.plugin_id).await?;
let permission = match method {
"host/session" => "session.read",
"host/terminal/read" => "terminal.read",
"host/terminal/execute" => "terminal.execute",
"host/filesystem/read" => "filesystem.read",
"host/storage/get" | "host/storage/set" => "storage",
"host/network/request" => "",
_ => return Err(AppError::Config("Unknown plugin host capability".into())),
};
if !permission.is_empty() {
plugin.require(permission).map_err(plugin_error)?;
}
let operation = async {
let manager = app.state::<Arc<SessionManager>>().inner().clone();
match method {
"host/session" => match &scope.session_id {
Some(id) => {
let info = require_session(app, &scope.window_label, id).await?;
Ok(
json!({"id":info.id,"name":info.name,"type":info.session_type,"connected":info.connected}),
)
}
None => Ok(Value::Null),
},
"host/terminal/read" => {
let id = session_id(&scope)?;
let lines = params
.get("lines")
.and_then(Value::as_u64)
.unwrap_or(100)
.clamp(1, 500) as usize;
Ok(json!({"output":manager.recent_output(id, lines)}))
}
"host/terminal/execute" => {
let id = session_id(&scope)?;
let command = text(&params, "command", 64 * 1024)?;
let risk = assess_command_risk(command);
self.approve(
app,
&scope,
token,
"terminal.execute",
&crate::core::ai::redact_sensitive_text(command),
risk.level.clone(),
)
.await?;
self.scope(app, token, caller_window).await?;
let result = execute_terminal_command(
manager,
TerminalExecuteRequest {
session_id: id.into(),
command: command.into(),
timeout_ms: params
.get("timeoutMs")
.and_then(Value::as_u64)
.unwrap_or(30_000)
.clamp(1000, 120_000),
},
None,
scope.cancellation.child_token(),
)
.await;
let request = serde_json::from_value(
json!({"action":"plugin_terminal_execute","source":"plugin","client":scope.plugin_id,
"capability":"terminal.execute","sessionId":id,"generatedCommand":crate::core::ai::redact_sensitive_text(command),
"riskLevel":risk.level,"executed":result.is_ok(),"success":result.is_ok(),"approvalDecision":"allow_once"}),
)?;
let _ = crate::core::ai::append_ai_audit(app, request);
serde_json::to_value(result?).map_err(Into::into)
}
"host/filesystem/read" => {
let id = session_id(&scope)?;
let path = text(&params, "path", 4096)?;
self.approve(
app,
&scope,
token,
"filesystem.read",
path,
crate::config::RiskLevel::Medium,
)
.await?;
self.scope(app, token, caller_window).await?;
let info = manager.session_info(id).await?;
if info.session_type == crate::core::SessionType::Local {
let cwd = manager.session_cwd(id).await?.ok_or_else(|| {
AppError::Config("Local session has no usable working directory".into())
})?;
let cwd = std::path::PathBuf::from(cwd).canonicalize()?;
let path = cwd.join(path).canonicalize()?;
if !path.starts_with(&cwd) || !path.is_file() {
return Err(AppError::Config("Local plugin file reads must stay inside the session working directory".into()));
}
let file = tokio::fs::File::open(path).await?;
let mut bytes = Vec::new();
file.take(256 * 1024 + 1).read_to_end(&mut bytes).await?;
if bytes.len() > 256 * 1024 {
return Err(AppError::Config(
"Plugin file read exceeds 256 KiB".into(),
));
}
Ok(
json!({"content":String::from_utf8(bytes).map_err(|_| AppError::Config("Plugin file read requires UTF-8 text".into()))?}),
)
} else {
let file = crate::core::capabilities::sftp::read_text(
manager,
id,
path,
256 * 1024,
)
.await?;
serde_json::to_value(file).map_err(Into::into)
}
}
"host/storage/get" | "host/storage/set" => {
let key = text(&params, "key", 128)?;
if !nyaterm_plugin_runtime::manifest::valid_id(key) {
return Err(AppError::Config("Invalid plugin storage key".into()));
}
let _lock = self.storage_lock.lock().await;
let path = self
.root
.join("data")
.join(&scope.plugin_id)
.join("settings.json");
let mut values: BTreeMap<String, Value> = if path.is_file() {
if std::fs::metadata(&path)?.len() > 1024 * 1024 {
return Err(AppError::Config("Plugin storage is too large".into()));
}
serde_json::from_slice(&std::fs::read(&path)?)?
} else {
BTreeMap::new()
};
if method == "host/storage/get" {
return Ok(values.get(key).cloned().unwrap_or(Value::Null));
}
let value = params.get("value").cloned().unwrap_or(Value::Null);
if value.is_null() {
values.remove(key);
} else {
values.insert(key.into(), value);
}
if values.len() > 128 || serde_json::to_vec(&values)?.len() > 1024 * 1024 {
return Err(AppError::Config("Plugin storage quota exceeded".into()));
}
nyaterm_plugin_runtime::registry::atomic_json(&path, &values)
.map_err(plugin_error)?;
Ok(Value::Null)
}
"host/network/request" => {
let url = reqwest::Url::parse(text(&params, "url", 4096)?)
.map_err(|e| AppError::Config(e.to_string()))?;
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
{
return Err(AppError::Config(
"Plugin network requests require HTTPS without URL credentials".into(),
));
}
let permission = format!("network:{}", url.origin().ascii_serialization());
plugin.require(&permission).map_err(plugin_error)?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(30))
.build()
.map_err(|e| AppError::Config(e.to_string()))?;
let method = params
.get("method")
.and_then(Value::as_str)
.unwrap_or("GET");
let mut request = match method {
"GET" => client.get(url),
"POST" => client.post(url),
_ => {
return Err(AppError::Config(
"Plugin network requests support GET and POST".into(),
));
}
};
if let Some(body) = params.get("body").and_then(Value::as_str) {
if body.len() > 256 * 1024 {
return Err(AppError::Config(
"Plugin request body is too large".into(),
));
}
request = request.body(body.to_string());
}
if let Some(content_type) = params.get("contentType").and_then(Value::as_str) {
if content_type.len() > 128 || content_type.contains(['\r', '\n']) {
return Err(AppError::Config("Invalid plugin content type".into()));
}
request = request.header(reqwest::header::CONTENT_TYPE, content_type);
}
let mut response = request
.send()
.await
.map_err(|e| AppError::Config(e.to_string()))?;
let status = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|h| h.to_str().ok())
.unwrap_or_default()
.to_string();
let mut body = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| AppError::Config(e.to_string()))?
{
if body.len() + chunk.len() > 1024 * 1024 {
return Err(AppError::Config(
"Plugin network response exceeds 1 MiB".into(),
));
}
body.extend_from_slice(&chunk);
}
Ok(
json!({"status":status,"contentType":content_type,"body":String::from_utf8_lossy(&body)}),
)
}
_ => unreachable!(),
}
};
let result = tokio::select! {
() = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin scope was revoked".into())),
result = tokio::time::timeout(Duration::from_mins(3), operation) => result.map_err(|_| AppError::Cancelled("Plugin host request timed out".into()))?,
};
self.scope(app, token, caller_window).await?;
result
}
async fn approve(
self: &Arc<Self>,
app: &tauri::AppHandle,
scope: &Scope,
token: &str,
capability: &str,
summary: &str,
risk: crate::config::RiskLevel,
) -> AppResult<()> {
self.scope(app, token, None).await?;
let plugin = self.plugin(&scope.plugin_id).await?;
let request_id = uuid::Uuid::new_v4().to_string();
let info = require_session(app, &scope.window_label, session_id(scope)?).await?;
let window = app
.get_webview_window(&scope.window_label)
.ok_or_else(|| AppError::Config("Plugin owner window is unavailable".into()))?;
let (sender, receiver) = oneshot::channel();
let mut approvals = self.approvals.lock().await;
if approvals.len() >= 32 {
return Err(AppError::Config("Too many pending plugin approvals".into()));
}
approvals.insert(
request_id.clone(),
Approval {
window_label: scope.window_label.clone(),
responder: sender,
},
);
drop(approvals);
let _guard = ApprovalGuard {
manager: Arc::downgrade(self),
window: window.clone(),
id: request_id.clone(),
};
let event = ApprovalRequest {
request_id: request_id.clone(),
plugin_id: scope.plugin_id.clone(),
plugin_name: plugin.active().map_err(plugin_error)?.manifest.name.clone(),
capability: capability.into(),
session_name: info.name,
summary: summary.into(),
risk,
};
if let Err(error) = window.emit("plugin-approval-request", &event) {
self.approvals.lock().await.remove(&request_id);
return Err(AppError::Config(error.to_string()));
}
let response = tokio::select! {
() = scope.cancellation.cancelled() => false,
result = tokio::time::timeout(Duration::from_mins(2), receiver) => result.is_ok_and(|r| r.unwrap_or(false)),
};
self.approvals.lock().await.remove(&request_id);
let _ = window.emit("plugin-approval-closed", &request_id);
if response {
Ok(())
} else {
Err(AppError::Cancelled(
"Plugin operation was not approved".into(),
))
}
}
}
fn session_id(scope: &Scope) -> AppResult<&str> {
scope.session_id.as_deref().ok_or_else(|| {
AppError::Config("Select an active terminal session for this plugin operation".into())
})
}
fn text<'a>(value: &'a Value, key: &str, limit: usize) -> AppResult<&'a str> {
value
.get(key)
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty() && text.len() <= limit && !text.contains('\0'))
.ok_or_else(|| AppError::Config(format!("Invalid plugin parameter: {key}")))
}
@@ -0,0 +1,95 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
;(function () {
// Wry on Windows injects initialization scripts into subframes too.
// Never initialize the native IPC transport or its key in plugin frames.
if (window.top !== window) return
/**
* A runtime generated key to ensure an IPC call comes from an initialized frame.
*
* This is declared outside the `window.__TAURI_INVOKE__` definition to prevent
* the key from being leaked by `window.__TAURI_INVOKE__.toString()`.
*/
const __TAURI_INVOKE_KEY__ = __TEMPLATE_invoke_key__
const processIpcMessage = __RAW_process_ipc_message_fn__
const osName = __TEMPLATE_os_name__
const fetchChannelDataCommand = __TEMPLATE_fetch_channel_data_command__
let customProtocolIpcFailed = false
// on Android we never use it because Android does not have support to reading the request body
const canUseCustomProtocol = osName !== 'android'
function sendIpcMessage(message) {
const { cmd, callback, error, payload, options } = message
if (
!customProtocolIpcFailed
&& (canUseCustomProtocol || cmd === fetchChannelDataCommand)
) {
const { contentType, data } = processIpcMessage(payload)
const headers = new Headers((options && options.headers) || {})
headers.set('Content-Type', contentType)
headers.set('Tauri-Callback', callback)
headers.set('Tauri-Error', error)
headers.set('Tauri-Invoke-Key', __TAURI_INVOKE_KEY__)
fetch(window.__TAURI_INTERNALS__.convertFileSrc(cmd, 'ipc'), {
method: 'POST',
body: data,
headers
})
.then((response) => {
const callbackId =
response.headers.get('Tauri-Response') === 'ok' ? callback : error
// we need to split here because on Android the content-type gets duplicated
switch ((response.headers.get('content-type') || '').split(',')[0]) {
case 'application/json':
return response.json().then((r) => [callbackId, r])
case 'text/plain':
return response.text().then((r) => [callbackId, r])
default:
return response.arrayBuffer().then((r) => [callbackId, r])
}
})
.then(
([callbackId, data]) => {
window.__TAURI_INTERNALS__.runCallback(callbackId, data)
},
(e) => {
console.warn(
'IPC custom protocol failed, Tauri will now use the postMessage interface instead',
e
)
// failed to use the custom protocol IPC (either the webview blocked a custom protocol or it was a CSP error)
// so we need to fallback to the postMessage interface
customProtocolIpcFailed = true
sendIpcMessage(message)
}
)
} else {
// otherwise use the postMessage interface
const { data } = processIpcMessage({
cmd,
callback,
error,
options: {
...options,
customProtocolIpcBlocked: customProtocolIpcFailed
},
payload,
__TAURI_INVOKE_KEY__
})
// `window.ipc.postMessage` came from `tauri-runtime-wry` > `wry` [`with_ipc_handler`](https://github.com/tauri-apps/wry/blob/a0403b9e2f1ff9d73be7dce1184f058afcaa1d82/src/lib.rs#L1130)
window.ipc.postMessage(data)
}
}
Object.defineProperty(window.__TAURI_INTERNALS__, 'postMessage', {
value: sendIpcMessage
})
})()
+84
View File
@@ -0,0 +1,84 @@
use crate::error::{AppError, AppResult};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct Activity {
requests: usize,
changing: bool,
}
#[derive(Default, Clone)]
pub(super) struct Lifecycle {
state: Arc<Mutex<HashMap<String, Activity>>>,
}
pub(super) struct Lease {
lifecycle: Lifecycle,
id: String,
mutation: bool,
}
impl Lifecycle {
pub fn acquire(&self, id: &str, mutation: bool) -> AppResult<Lease> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let activity = state.entry(id.into()).or_default();
if activity.changing || (mutation && activity.requests > 0) {
return Err(AppError::Config(
"Plugin is busy; wait for its active operations to finish.".into(),
));
}
if mutation {
activity.changing = true;
} else {
activity.requests += 1;
}
Ok(Lease {
lifecycle: self.clone(),
id: id.into(),
mutation,
})
}
}
impl Drop for Lease {
fn drop(&mut self) {
let mut state = self
.lifecycle
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(activity) = state.get_mut(&self.id) {
if self.mutation {
activity.changing = false;
} else {
activity.requests = activity.requests.saturating_sub(1);
}
if !activity.changing && activity.requests == 0 {
state.remove(&self.id);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mutations_and_requests_are_admitted_atomically_per_plugin() {
let lifecycle = Lifecycle::default();
let request = lifecycle.acquire("a", false).unwrap();
assert!(lifecycle.acquire("a", true).is_err());
let other = lifecycle.acquire("b", true).unwrap();
assert!(lifecycle.acquire("b", false).is_err());
drop(request);
let update = lifecycle.acquire("a", true).unwrap();
assert!(lifecycle.acquire("a", false).is_err());
drop(update);
drop(other);
assert!(lifecycle.acquire("a", false).is_ok());
}
}
+700
View File
@@ -0,0 +1,700 @@
mod gateway;
mod lifecycle;
mod protocol;
use crate::cmd::app::AppLockState;
use crate::core::SessionManager;
use crate::error::{AppError, AppResult};
use lifecycle::Lifecycle;
use nyaterm_plugin_runtime::diagnostics::{Diagnostics, Snapshot, Status};
use nyaterm_plugin_runtime::package::{PackagePreview, prepare};
use nyaterm_plugin_runtime::registry::{InstalledPlugin, Registry};
use nyaterm_plugin_runtime::sidecar::{BackendEvent, HostHandler, Sidecar};
use serde::Serialize;
use serde_json::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Weak};
use std::time::{Duration, Instant};
use tauri::{Emitter, Manager};
use tokio::sync::{Mutex, RwLock, oneshot};
use tokio_util::sync::CancellationToken;
pub use protocol::serve_asset;
/// Preserve Tauri's IPC transport semantics, but initialize it only in the
/// trusted top-level document. Wry's Windows initialization ignores main-only.
pub fn guarded_invoke_script() -> String {
include_str!("ipc_transport.js")
.replace("__TEMPLATE_invoke_key__", "__INVOKE_KEY__")
.replace(
"__TEMPLATE_os_name__",
&serde_json::to_string(std::env::consts::OS).unwrap(),
)
.replace(
"__TEMPLATE_fetch_channel_data_command__",
"\"plugin:__TAURI_CHANNEL__|fetch\"",
)
.replace(
"__RAW_process_ipc_message_fn__",
include_str!("process_ipc_message.js"),
)
}
#[cfg(test)]
mod ipc_tests {
#[test]
fn guarded_script_resolves_transport_templates() {
let script = super::guarded_invoke_script();
assert!(!script.contains("__TEMPLATE_"));
assert!(!script.contains("__RAW_"));
assert!(script.contains("const __TAURI_INVOKE_KEY__ = __INVOKE_KEY__"));
assert!(
script.find("if (window.top !== window) return").unwrap()
< script.find("const __TAURI_INVOKE_KEY__").unwrap()
);
}
}
#[allow(clippy::needless_pass_by_value)] // Consuming signature is used directly by Result::map_err.
pub(super) fn plugin_error(error: nyaterm_plugin_runtime::Error) -> AppError {
AppError::Config(error.to_string())
}
#[derive(Clone)]
pub(super) struct Scope {
plugin_id: String,
version: String,
window_label: String,
session_id: Option<String>,
expires: Instant,
cancellation: CancellationToken,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PluginScope {
pub token: String,
pub plugin_id: String,
pub version: String,
}
pub(super) struct Approval {
window_label: String,
responder: oneshot::Sender<bool>,
}
type BackendSlot = Arc<Mutex<Option<Arc<Sidecar>>>>;
pub struct PluginManager {
root: PathBuf,
app_version: String,
registry: RwLock<Option<Registry>>,
startup_error: Option<String>,
scopes: Mutex<HashMap<String, Scope>>,
approvals: Mutex<HashMap<String, Approval>>,
lifecycle: Lifecycle,
backends: Mutex<HashMap<String, BackendSlot>>,
storage_lock: Mutex<()>,
diagnostics: Diagnostics,
}
impl PluginManager {
pub fn new(root: PathBuf, app_version: String) -> Arc<Self> {
let (registry, startup_error) = match Registry::open(root.clone(), app_version.clone()) {
Ok(registry) => (Some(registry), None),
Err(error) => (None, Some(error.to_string())),
};
Arc::new(Self {
root,
app_version,
registry: RwLock::new(registry),
startup_error,
scopes: Mutex::new(HashMap::new()),
approvals: Mutex::new(HashMap::new()),
lifecycle: Lifecycle::default(),
backends: Mutex::new(HashMap::new()),
storage_lock: Mutex::new(()),
diagnostics: Diagnostics::default(),
})
}
fn registry_error(&self) -> AppError {
AppError::Config(
self.startup_error
.clone()
.unwrap_or_else(|| "Plugin registry is unavailable".into()),
)
}
pub async fn list(&self) -> AppResult<Vec<InstalledPlugin>> {
Ok(self
.registry
.read()
.await
.as_ref()
.ok_or_else(|| self.registry_error())?
.list())
}
pub async fn inspect(&self, path: PathBuf) -> AppResult<PackagePreview> {
let root = self.root.clone();
let version = self.app_version.clone();
tokio::task::spawn_blocking(move || {
prepare(&path, &root, &version).map(|package| package.preview)
})
.await
.map_err(|e| AppError::Config(e.to_string()))?
.map_err(plugin_error)
}
pub async fn install(
&self,
app: &tauri::AppHandle,
path: PathBuf,
digest: String,
) -> AppResult<InstalledPlugin> {
ensure_unlocked(app)?;
let root = self.root.clone();
let version = self.app_version.clone();
let prepared = tokio::task::spawn_blocking(move || prepare(&path, &root, &version))
.await
.map_err(|e| AppError::Config(e.to_string()))?
.map_err(plugin_error)?;
let id = prepared.preview.manifest.id.clone();
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(&id, true)?;
self.revoke_plugin(&id).await;
let result = self
.registry
.write()
.await
.as_mut()
.ok_or_else(|| self.registry_error())?
.install(prepared, &digest)
.map_err(plugin_error)?;
let _ = app.emit("plugins-changed", ());
Ok(result)
}
pub async fn configure(
&self,
app: &tauri::AppHandle,
id: &str,
expected_version: &str,
enabled: bool,
permissions: Vec<String>,
) -> AppResult<()> {
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, true)?;
if self.plugin(id).await?.active_version != expected_version {
return Err(AppError::Config(
"Plugin version changed; review its permissions again".into(),
));
}
self.revoke_plugin(id).await;
self.registry
.write()
.await
.as_mut()
.ok_or_else(|| self.registry_error())?
.configure(id, enabled, permissions)
.map_err(plugin_error)?;
let _ = app.emit("plugins-changed", ());
Ok(())
}
pub async fn activate_version(
&self,
app: &tauri::AppHandle,
id: &str,
version: &str,
) -> AppResult<()> {
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, true)?;
self.revoke_plugin(id).await;
self.registry
.write()
.await
.as_mut()
.ok_or_else(|| self.registry_error())?
.activate_version(id, version)
.map_err(plugin_error)?;
let _ = app.emit("plugins-changed", ());
Ok(())
}
pub async fn uninstall(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> {
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, true)?;
self.revoke_plugin(id).await;
self.registry
.write()
.await
.as_mut()
.ok_or_else(|| self.registry_error())?
.uninstall(id)
.map_err(plugin_error)?;
self.diagnostics.remove(id);
let _ = app.emit("plugins-changed", ());
Ok(())
}
pub async fn create_scope(
&self,
app: &tauri::AppHandle,
window_label: &str,
plugin_id: &str,
session_id: Option<String>,
) -> AppResult<PluginScope> {
ensure_unlocked(app)?;
if !crate::window_state::is_main_window_label(window_label) {
return Err(AppError::Config(
"Plugin scopes can only be created by a main window".into(),
));
}
let _lease = self.lifecycle.acquire(plugin_id, false)?;
let plugin = self.plugin(plugin_id).await?;
if !plugin.enabled {
return Err(AppError::Config("Plugin is disabled".into()));
}
plugin
.active()
.map_err(plugin_error)?
.manifest
.validate(&self.app_version)
.map_err(plugin_error)?;
if let Some(id) = &session_id {
require_session(app, window_label, id).await?;
}
let scope = Scope {
plugin_id: plugin_id.into(),
version: plugin.active_version.clone(),
window_label: window_label.into(),
session_id,
expires: Instant::now() + Duration::from_mins(30),
cancellation: CancellationToken::new(),
};
let token = uuid::Uuid::new_v4().to_string();
let mut scopes = self.scopes.lock().await;
scopes.retain(|_, scope| {
if scope.expires <= Instant::now() {
scope.cancellation.cancel();
false
} else {
true
}
});
if scopes.len() >= 256 {
return Err(AppError::Config("Too many active plugin views".into()));
}
ensure_unlocked(app)?;
scopes.insert(token.clone(), scope);
Ok(PluginScope {
token,
plugin_id: plugin_id.into(),
version: plugin.active_version,
})
}
pub async fn close_scope(&self, window_label: &str, token: &str) -> AppResult<()> {
let mut scopes = self.scopes.lock().await;
if scopes
.get(token)
.is_some_and(|s| s.window_label != window_label)
{
return Err(AppError::Config(
"Plugin scope belongs to another window".into(),
));
}
if let Some(scope) = scopes.remove(token) {
scope.cancellation.cancel();
}
Ok(())
}
pub async fn revoke_window(&self, label: &str) {
self.scopes.lock().await.retain(|_, scope| {
if scope.window_label == label {
scope.cancellation.cancel();
false
} else {
true
}
});
self.approvals
.lock()
.await
.retain(|_, approval| approval.window_label != label);
}
pub async fn revoke_session(&self, session_id: &str) {
self.scopes.lock().await.retain(|_, scope| {
if scope.session_id.as_deref() == Some(session_id) {
scope.cancellation.cancel();
false
} else {
true
}
});
}
pub async fn revoke_all(&self) {
for (_, scope) in self.scopes.lock().await.drain() {
scope.cancellation.cancel();
}
self.approvals.lock().await.clear();
for (id, backend) in self.backends.lock().await.drain() {
self.diagnostics.stop(&id);
if let Some(sidecar) = backend.lock().await.take() {
sidecar.stop();
}
}
}
async fn revoke_plugin(&self, id: &str) {
self.diagnostics.stop(id);
self.scopes.lock().await.retain(|_, scope| {
if scope.plugin_id == id {
scope.cancellation.cancel();
false
} else {
true
}
});
if let Some(backend) = self.backends.lock().await.remove(id)
&& let Some(sidecar) = backend.lock().await.take()
{
sidecar.stop();
}
}
async fn plugin(&self, id: &str) -> AppResult<InstalledPlugin> {
Ok(self
.registry
.read()
.await
.as_ref()
.ok_or_else(|| self.registry_error())?
.get(id)
.map_err(plugin_error)?
.clone())
}
async fn scope(
&self,
app: &tauri::AppHandle,
token: &str,
caller_window: Option<&str>,
) -> AppResult<Scope> {
ensure_unlocked(app)?;
let scope = self
.scopes
.lock()
.await
.get(token)
.cloned()
.ok_or_else(|| AppError::Config("Plugin scope has expired or been revoked".into()))?;
if scope.expires <= Instant::now()
|| scope.cancellation.is_cancelled()
|| caller_window.is_some_and(|label| label != scope.window_label)
|| app.get_webview_window(&scope.window_label).is_none()
{
return Err(AppError::Config(
"Plugin scope is no longer available in this window".into(),
));
}
let plugin = self.plugin(&scope.plugin_id).await?;
if !plugin.enabled || plugin.active_version != scope.version {
return Err(AppError::Config(
"Plugin scope version is no longer active".into(),
));
}
if let Some(id) = &scope.session_id {
require_session(app, &scope.window_label, id).await?;
}
Ok(scope)
}
pub async fn respond_approval(
&self,
window_label: &str,
request_id: &str,
approved: bool,
) -> AppResult<()> {
let mut approvals = self.approvals.lock().await;
if approvals
.get(request_id)
.is_none_or(|a| a.window_label != window_label)
{
return Err(AppError::Config(
"Plugin approval is not pending in this window".into(),
));
}
if let Some(approval) = approvals.remove(request_id) {
let _ = approval.responder.send(approved);
}
Ok(())
}
pub async fn backend_call(
self: &Arc<Self>,
app: &tauri::AppHandle,
window_label: &str,
token: &str,
method: &str,
params: Value,
) -> AppResult<Value> {
let scope = self.scope(app, token, Some(window_label)).await?;
let _lease = self.lifecycle.acquire(&scope.plugin_id, false)?;
let plugin = self.plugin(&scope.plugin_id).await?;
plugin.require("native").map_err(plugin_error)?;
if !(method.starts_with("ui/")
|| plugin
.active()
.map_err(plugin_error)?
.manifest
.contributions
.commands
.iter()
.any(|c| c.method.as_deref() == Some(method)))
|| method.len() > 128
{
return Err(AppError::Config(
"Backend method is not an allowed UI or contributed command method".into(),
));
}
if serde_json::to_vec(&params)?.len() > 1024 * 1024 {
return Err(AppError::Config("Plugin request is too large".into()));
}
let backend = self
.backends
.lock()
.await
.entry(scope.plugin_id.clone())
.or_insert_with(|| Arc::new(Mutex::new(None)))
.clone();
let mut sidecar = tokio::select! {
() = scope.cancellation.cancelled() => return Err(AppError::Cancelled("Plugin activation was cancelled".into())),
result = tokio::time::timeout(Duration::from_secs(15), backend.lock()) => result.map_err(|_| AppError::Cancelled("Plugin activation timed out".into()))?,
};
self.scope(app, token, Some(window_label)).await?;
if sidecar.as_ref().is_none_or(|s| !s.is_running()) {
let root = self
.registry
.read()
.await
.as_ref()
.ok_or_else(|| self.registry_error())?
.active_path(&scope.plugin_id)
.map_err(plugin_error)?;
let ticket = self.diagnostics.begin(&scope.plugin_id, &scope.version);
let diagnostics = self.diagnostics.clone();
let event_ticket = ticket.clone();
let observer = Arc::new(move |event: BackendEvent| {
let (status, level, source, message) = match event {
BackendEvent::Notification { method, params } if method == "event/log" => {
let Some(message) = params.get("message").and_then(Value::as_str) else {
return;
};
let level = params
.get("level")
.and_then(Value::as_str)
.unwrap_or("info");
(None, level.to_owned(), "plugin", message.to_owned())
}
BackendEvent::Stderr(message) => (None, "warn".into(), "stderr", message),
BackendEvent::ProtocolError(message) => {
(Some(Status::Error), "error".into(), "protocol", message)
}
BackendEvent::Stopped(message) => (
Some(Status::Error),
"error".into(),
"process",
format!("Native backend exited: {message}"),
),
_ => return,
};
diagnostics.update(
&event_ticket,
status,
&level,
source,
&crate::core::ai::redact_sensitive_text(&message),
);
});
let activation = Sidecar::spawn_observed(
&root,
&plugin.active().map_err(plugin_error)?.manifest,
&self.app_version,
&plugin.granted_permissions,
Arc::new(PluginHostHandler {
manager: Arc::downgrade(self),
app: app.clone(),
plugin_id: scope.plugin_id.clone(),
}),
Some(observer),
);
let activated = tokio::select! {
() = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin activation was cancelled".into())),
result = activation => result.map_err(plugin_error),
};
let activated = match activated {
Ok(activated) => activated,
Err(error) => {
if matches!(&error, AppError::Cancelled(_)) {
self.diagnostics.update(
&ticket,
Some(Status::Stopped),
"warn",
"activation",
"Native backend activation cancelled",
);
self.diagnostics.stop(&scope.plugin_id);
} else {
self.diagnostics.update(
&ticket,
Some(Status::Error),
"error",
"activation",
&crate::core::ai::redact_sensitive_text(&error.to_string()),
);
}
return Err(error);
}
};
if let Err(error) = self.scope(app, token, Some(window_label)).await {
self.diagnostics.stop(&scope.plugin_id);
return Err(error);
}
if !activated.is_running() {
return Err(AppError::Config("Plugin exited during activation".into()));
}
self.diagnostics.update(
&ticket,
Some(Status::Running),
"info",
"host",
"Native backend initialized",
);
*sidecar = Some(Arc::new(activated));
}
let running = sidecar.as_ref().unwrap().clone();
let request_ticket = self.diagnostics.ticket(&scope.plugin_id);
drop(sidecar);
let result = tokio::select! {
() = scope.cancellation.cancelled() => Err(AppError::Cancelled("Plugin request was cancelled".into())),
result = running.request(method, serde_json::json!({"scopeToken":token,"input":params}), Duration::from_secs(170)) => result.map_err(plugin_error),
};
if let Err(error) = &result
&& let Some(ticket) = request_ticket
{
self.diagnostics.update(
&ticket,
None,
"error",
"request",
&crate::core::ai::redact_sensitive_text(&error.to_string()),
);
}
self.scope(app, token, Some(window_label)).await?;
result
}
pub async fn diagnostic_snapshot(
&self,
app: &tauri::AppHandle,
id: &str,
) -> AppResult<Snapshot> {
ensure_unlocked(app)?;
let plugin = self.plugin(id).await?;
Ok(self.diagnostics.snapshot(id, &plugin.active_version))
}
pub async fn clear_logs(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> {
ensure_unlocked(app)?;
self.plugin(id).await?;
self.diagnostics.clear(id);
Ok(())
}
pub async fn stop_backend(&self, app: &tauri::AppHandle, id: &str) -> AppResult<()> {
ensure_unlocked(app)?;
let _lease = self.lifecycle.acquire(id, true)?;
self.plugin(id).await?;
self.diagnostics.stop(id);
if let Some(backend) = self.backends.lock().await.remove(id)
&& let Some(sidecar) = backend.lock().await.take()
{
sidecar.stop();
}
Ok(())
}
}
struct PluginHostHandler {
manager: Weak<PluginManager>,
app: tauri::AppHandle,
plugin_id: String,
}
#[async_trait::async_trait]
impl HostHandler for PluginHostHandler {
async fn call(&self, method: &str, params: Value) -> nyaterm_plugin_runtime::Result<Value> {
let manager = self.manager.upgrade().ok_or_else(|| {
nyaterm_plugin_runtime::Error::Runtime("Plugin host has stopped".into())
})?;
let token = params
.get("scopeToken")
.and_then(Value::as_str)
.ok_or_else(|| {
nyaterm_plugin_runtime::Error::Invalid("Host request needs a scope token".into())
})?;
let scope = manager
.scope(&self.app, token, None)
.await
.map_err(|e| nyaterm_plugin_runtime::Error::Invalid(e.to_string()))?;
if scope.plugin_id != self.plugin_id {
return Err(nyaterm_plugin_runtime::Error::Invalid(
"Scope belongs to another plugin".into(),
));
}
manager
.host_call(
&self.app,
None,
token,
method,
params.get("input").cloned().unwrap_or(Value::Null),
)
.await
.map_err(|e| nyaterm_plugin_runtime::Error::Runtime(e.to_string()))
}
}
pub(super) fn ensure_unlocked(app: &tauri::AppHandle) -> AppResult<()> {
if app
.try_state::<AppLockState>()
.is_some_and(|s| s.is_locked())
{
return Err(AppError::Config(
"Plugins are unavailable while NyaTerm is locked".into(),
));
}
Ok(())
}
async fn require_session(
app: &tauri::AppHandle,
window_label: &str,
id: &str,
) -> AppResult<crate::core::SessionInfo> {
let manager = app.state::<Arc<SessionManager>>();
let info = manager.session_info(id).await?;
if !info.connected || info.owner_window_label.as_deref() != Some(window_label) {
return Err(AppError::Config(
"Session is not available in this plugin window scope".into(),
));
}
Ok(info)
}
@@ -0,0 +1,46 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// this is a function and not an iife so use it carefully
(function (message) {
if (
message instanceof ArrayBuffer
|| ArrayBuffer.isView(message)
|| Array.isArray(message)
) {
return {
contentType: 'application/octet-stream',
data: message
}
} else {
const data = JSON.stringify(message, (_k, val) => {
// if this value changes, make sure to update it in:
// 1. ipc.js
// 2. core.ts
const SERIALIZE_TO_IPC_FN = '__TAURI_TO_IPC_KEY__'
if (val instanceof Map) {
return Object.fromEntries(val.entries())
} else if (val instanceof Uint8Array) {
return Array.from(val)
} else if (val instanceof ArrayBuffer) {
return Array.from(new Uint8Array(val))
} else if (
typeof val === 'object'
&& val !== null
&& SERIALIZE_TO_IPC_FN in val
) {
return val[SERIALIZE_TO_IPC_FN]()
} else {
return val
}
})
return {
contentType: 'application/json',
data
}
}
})
+77
View File
@@ -0,0 +1,77 @@
use super::{PluginManager, plugin_error};
use crate::error::{AppError, AppResult};
use nyaterm_plugin_runtime::manifest::validate_path;
use std::sync::Arc;
use tauri::Manager;
use tokio::io::AsyncReadExt;
/// A separate document response avoids inheriting the main application's CSP.
/// The opaque-origin iframe still cannot access Tauri IPC or its parent's DOM.
pub async fn serve_asset(app: &tauri::AppHandle, uri: &str) -> tauri::http::Response<Vec<u8>> {
let result = asset(app, uri).await;
match result {
Ok((body, mime)) => tauri::http::Response::builder()
.header("Content-Type", mime)
.header("Content-Security-Policy", "default-src 'none'; script-src 'unsafe-inline' nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; style-src 'unsafe-inline' nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; img-src data: nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; font-src data: nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'")
.header("Access-Control-Allow-Origin", "*")
.header("X-Content-Type-Options", "nosniff")
.header("Referrer-Policy", "no-referrer")
.header("Cache-Control", "no-store")
.body(body).unwrap(),
Err(_) => tauri::http::Response::builder().status(403)
.header("Content-Type", "text/plain").header("Cache-Control", "no-store")
.body(b"Plugin asset is unavailable".to_vec()).unwrap(),
}
}
async fn asset(app: &tauri::AppHandle, uri: &str) -> AppResult<(Vec<u8>, &'static str)> {
let url = reqwest::Url::parse(uri).map_err(|e| AppError::Config(e.to_string()))?;
let decoded = urlencoding::decode(url.path()).map_err(|e| AppError::Config(e.to_string()))?;
let path = decoded.trim_start_matches('/');
let (token, relative) = path
.split_once('/')
.ok_or_else(|| AppError::Config("Invalid plugin asset URL".into()))?;
if token.len() != 36 || !(relative.starts_with("ui/") || relative.starts_with("assets/")) {
return Err(AppError::Config("Invalid plugin asset URL".into()));
}
validate_path(relative).map_err(plugin_error)?;
let manager = app.state::<Arc<PluginManager>>();
let scope = manager.scope(app, token, None).await?;
let _lease = manager.lifecycle.acquire(&scope.plugin_id, false)?;
manager.scope(app, token, None).await?;
let path = manager
.registry
.read()
.await
.as_ref()
.ok_or_else(|| manager.registry_error())?
.asset(&scope.plugin_id, relative)
.map_err(plugin_error)?;
if std::fs::metadata(&path)?.len() > 4 * 1024 * 1024 {
return Err(AppError::Config("Plugin UI asset is too large".into()));
}
let file = tokio::fs::File::open(path).await?;
let mut body = Vec::new();
file.take(4 * 1024 * 1024 + 1)
.read_to_end(&mut body)
.await?;
if body.len() > 4 * 1024 * 1024 {
return Err(AppError::Config("Plugin UI asset is too large".into()));
}
manager.scope(app, token, None).await?;
let mime = match relative.rsplit('.').next().unwrap_or_default() {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"svg" => "image/svg+xml",
"webp" => "image/webp",
"gif" => "image/gif",
"woff" => "font/woff",
"woff2" => "font/woff2",
_ => return Err(AppError::Config("Unsupported plugin UI asset type".into())),
};
Ok((body, mime))
}
+4 -1
View File
@@ -19,7 +19,7 @@ use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex as StdMutex, OnceLock};
use std::time::Duration;
use tauri::Emitter;
use tauri::{Emitter, Manager};
use time::format_description::well_known::Rfc3339;
use tokio::sync::{Mutex, Notify, mpsc, oneshot};
@@ -848,6 +848,9 @@ impl SessionManager {
}
if removed {
if let Some(app) = self.app_handle.get() {
if let Some(plugins) = app.try_state::<Arc<super::plugins::PluginManager>>() {
plugins.revoke_session(id).await;
}
let _ = app.emit("sessions-changed", ());
crate::tray::schedule_refresh(app);
}
+34 -1
View File
@@ -69,8 +69,12 @@ pub fn run() {
runtime.config_dir().to_path_buf(),
runtime.executable_dir().to_path_buf(),
);
let plugin_manager = core::plugins::PluginManager::new(
runtime.config_dir().join("plugins"),
env!("CARGO_PKG_VERSION").into(),
);
let builder = tauri::Builder::default();
let builder = tauri::Builder::default().invoke_system(core::plugins::guarded_invoke_script());
#[cfg(not(any(target_os = "android", target_os = "ios")))]
let builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
if external_open::handle_external_open_args(
@@ -117,6 +121,7 @@ pub fn run() {
.manage(docker_sudo_manager.clone())
.manage(remote_stats_sampler.clone())
.manage(mcp_manager.clone())
.manage(plugin_manager)
.manage(app_lock_state)
.manage(external_open_state)
.manage(portable_update_state)
@@ -139,7 +144,32 @@ pub fn run() {
Ok(())
})
.on_window_event(app::on_window_event)
.register_asynchronous_uri_scheme_protocol(
"nyaterm-plugin",
|context, request, responder| {
let app = context.app_handle().clone();
tauri::async_runtime::spawn(async move {
responder.respond(
core::plugins::serve_asset(&app, &request.uri().to_string()).await,
);
});
},
)
.invoke_handler(tauri::generate_handler![
cmd::plugins::get_plugin_diagnostics,
cmd::plugins::clear_plugin_logs,
cmd::plugins::stop_plugin_backend,
cmd::plugins::list_plugins,
cmd::plugins::inspect_plugin_package,
cmd::plugins::install_plugin_package,
cmd::plugins::configure_plugin,
cmd::plugins::activate_plugin_version,
cmd::plugins::uninstall_plugin,
cmd::plugins::create_plugin_scope,
cmd::plugins::close_plugin_scope,
cmd::plugins::plugin_host_call,
cmd::plugins::plugin_backend_call,
cmd::plugins::respond_plugin_approval,
cmd::app::quit_application,
cmd::app::hide_main_window,
cmd::app::open_download_dir,
@@ -445,6 +475,9 @@ pub fn run() {
tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. }
) {
if matches!(_event, tauri::RunEvent::Exit) {
if let Some(manager) = _app.try_state::<Arc<core::plugins::PluginManager>>() {
tauri::async_runtime::block_on(manager.revoke_all());
}
if let Some(manager) = _app.try_state::<Arc<RecordingManager>>() {
manager.finish_all_scopes();
}
+1 -1
View File
@@ -27,7 +27,7 @@
"enable": true,
"scope": []
},
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src ipc: http://ipc.localhost; frame-src nyaterm-plugin: http://nyaterm-plugin.localhost https://nyaterm-plugin.localhost"
}
},
"bundle": {
+19 -1
View File
@@ -12,6 +12,8 @@ import type { RdpCertificateVerifyRequest } from "./components/dialog/connection
import type { DockerSudoPasswordRequest } from "./components/dialog/docker/DockerSudoPasswordDialog";
import type { QuickSwitcherSession } from "./components/dialog/terminal/SessionQuickSwitcherDialog";
import { useApp } from "./context/AppContext";
import { usePlugins } from "./context/PluginContext";
import { activeManifest, parsePluginPanelId } from "./lib/plugins";
import { TransferProvider } from "./context/TransferContext";
import { useActivityBarController } from "./hooks/useActivityBarController";
import { useAppWindowEvents } from "./hooks/useAppWindowEvents";
@@ -171,6 +173,7 @@ function isSftpOnlySession(
/** Root layout: header, activity bars, sidebars, terminal area, dialogs. */
function App() {
const { plugins } = usePlugins();
useMacSelectionGuard();
// Keep dynamic session titles (local PTY shell integration) flowing for the
// main window's tab labels and window title.
@@ -3058,7 +3061,22 @@ function App() {
const getPanelTitle = useCallback(
(panelId: string) => {
const pluginPanel = parsePluginPanelId(panelId);
if (pluginPanel) {
const plugin = plugins.find(
(plugin) => plugin.id === pluginPanel.pluginId,
);
return (
(plugin &&
activeManifest(plugin)?.contributions.panels.find(
(panel) => panel.id === pluginPanel.panelId,
)?.title) ||
t("plugins.title")
);
}
switch (panelId) {
case "plugins":
return t("plugins.title");
case "securityAuth":
return t("securityAuth.title");
case "aiAssistant":
@@ -3069,7 +3087,7 @@ function App() {
return t(`panel.${panelId}`);
}
},
[t],
[t, plugins],
);
const renderPanelContent = useCallback(
+8 -1
View File
@@ -14,6 +14,9 @@ import ProcessManager from "@/components/panel/ProcessManager";
import RecordingPanel from "@/components/panel/RecordingPanel";
import ResourceMonitor from "@/components/panel/ResourceMonitor";
import SyncBackupHistoryPanel from "@/components/panel/SyncBackupHistoryPanel";
import { PluginPanel } from "@/components/plugins/PluginPanel";
import { PluginsPanel } from "@/components/plugins/PluginsPanel";
import { parsePluginPanelId } from "@/lib/plugins";
import SavedConnections from "@/components/panel/saved-connections";
import SecurityAuthPanel from "@/components/panel/security-auth";
import type { NetworkHistoryStore } from "@/hooks/useNetworkHistory";
@@ -144,6 +147,8 @@ export default function AppPanelContent({
);
case "network":
return <NetworkPanel />;
case "plugins":
return <PluginsPanel sessionId={activeSessionId} />;
case "notes":
return <NotesPanel />;
case "securityAuth":
@@ -218,7 +223,9 @@ export default function AppPanelContent({
case "aiAssistant":
return null;
default:
return null;
return parsePluginPanelId(panelId) && panelId ? (
<PluginPanel activityId={panelId} sessionId={activeSessionId} />
) : null;
}
})();
@@ -3,6 +3,7 @@ import { FolderOpen } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MdContentCopy, MdDelete, MdDriveFileRenameOutline, MdEdit, MdLink } from "react-icons/md";
import { toast } from "sonner";
import { PluginContextMenuItems } from "@/components/plugins/PluginContextMenuItems";
import { Badge } from "@/components/ui/badge";
import {
ContextMenu,
@@ -741,6 +742,7 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
? t("savedConnections.deleteSelected")
: t("savedConnections.delete")}
</ContextMenuItem>
<PluginContextMenuItems menu="connection" connectionId={conn.id} />
</ContextMenuContent>
</ContextMenu>
);
@@ -0,0 +1,96 @@
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { pluginApi } from "@/lib/plugins";
import type { PluginApprovalRequest } from "@/types/plugins";
export function PluginApprovalHost() {
const { t } = useTranslation();
const [requests, setRequests] = useState<PluginApprovalRequest[]>([]);
const current = requests[0] ?? null;
useEffect(() => {
let disposed = false;
const disposers: (() => void)[] = [];
const register = (promise: Promise<() => void>) => {
void promise
.then((dispose) => (disposed ? dispose() : disposers.push(dispose)))
.catch(() => {});
};
register(
listen<PluginApprovalRequest>("plugin-approval-request", (event) => {
if (!disposed)
setRequests((items) =>
items.some((item) => item.requestId === event.payload.requestId)
? items
: [...items, event.payload],
);
}),
);
register(
listen<string>("plugin-approval-closed", (event) => {
if (!disposed)
setRequests((items) =>
items.filter((item) => item.requestId !== event.payload),
);
}),
);
register(
listen<{ locked: boolean }>("app-lock-state-changed", (event) => {
if (!disposed && event.payload.locked) setRequests([]);
}),
);
return () => {
disposed = true;
for (const dispose of disposers) dispose();
};
}, []);
const respond = (approved: boolean) => {
if (!current) return;
setRequests((items) =>
items.filter((item) => item.requestId !== current.requestId),
);
void pluginApi.approve(current.requestId, approved).catch(() => {});
};
return (
<AlertDialog
open={current !== null}
onOpenChange={(open) => !open && respond(false)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("plugins.approvalTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("plugins.approvalDescription", { name: current?.pluginName })}
</AlertDialogDescription>
</AlertDialogHeader>
{current && (
<div className="space-y-2 rounded-md border p-3 text-sm">
<p>
{current.sessionName} · {current.capability} · {current.risk}
</p>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all rounded bg-muted p-2 text-xs">
{current.summary}
</pre>
</div>
)}
<AlertDialogFooter>
<Button variant="outline" onClick={() => respond(false)}>
{t("plugins.deny")}
</Button>
<Button onClick={() => respond(true)}>
{t("plugins.allowOnce")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { usePlugins } from "@/context/PluginContext";
import { getErrorMessage } from "@/lib/errors";
import {
activeManifest,
PLUGIN_OPEN_EVENT,
pluginApi,
type PluginOpenIntent,
} from "@/lib/plugins";
export function PluginCommandHost() {
const { t } = useTranslation();
const { plugins, locked } = usePlugins();
const currentPlugins = useRef(plugins);
currentPlugins.current = plugins;
const lockedRef = useRef(locked);
lockedRef.current = locked;
const [result, setResult] = useState<{
title: string;
output: string;
busy: boolean;
} | null>(null);
const activeToken = useRef<string | null>(null);
const sequence = useRef(0);
useEffect(() => {
const receive = (event: Event) => {
const intent = (event as CustomEvent<PluginOpenIntent>).detail;
if (!intent.commandId || lockedRef.current || activeToken.current) return;
const plugin = currentPlugins.current.find(
(plugin) => plugin.id === intent.pluginId && plugin.enabled,
);
const command =
plugin &&
activeManifest(plugin)?.contributions.commands.find(
(command) => command.id === intent.commandId,
);
if (!plugin || !command?.method) return;
const request = ++sequence.current;
setResult({ title: command.title, output: "", busy: true });
void (async () => {
let token: string | undefined;
try {
const scope = await pluginApi.createScope(
plugin.id,
intent.sessionId ?? null,
);
token = scope.token;
if (request !== sequence.current) return;
activeToken.current = token;
const output = await pluginApi.backendCall(
token,
command.method as string,
{ connectionId: intent.connectionId ?? null },
);
if (request === sequence.current)
setResult({
title: command.title,
output:
typeof output === "string"
? output
: JSON.stringify(output, null, 2),
busy: false,
});
} catch (error) {
if (request === sequence.current) {
setResult(null);
toast.error(getErrorMessage(error));
}
} finally {
if (token) await pluginApi.closeScope(token).catch(() => {});
if (request === sequence.current) activeToken.current = null;
}
})();
};
window.addEventListener(PLUGIN_OPEN_EVENT, receive);
return () => {
window.removeEventListener(PLUGIN_OPEN_EVENT, receive);
sequence.current += 1;
if (activeToken.current)
void pluginApi.closeScope(activeToken.current).catch(() => {});
};
}, []);
const close = useCallback(() => {
sequence.current += 1;
setResult(null);
if (activeToken.current)
void pluginApi.closeScope(activeToken.current).catch(() => {});
activeToken.current = null;
}, []);
useEffect(() => {
if (locked) close();
}, [locked, close]);
return (
<Dialog open={result !== null} onOpenChange={(open) => !open && close()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{result?.title}</DialogTitle>
<DialogDescription>{t("plugins.commandResult")}</DialogDescription>
</DialogHeader>
<pre
className="max-h-96 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-3 text-xs"
aria-busy={result?.busy}
>
{result?.busy ? t("plugins.running") : result?.output}
</pre>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,45 @@
import { Puzzle } from "lucide-react";
import {
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
import { usePlugins } from "@/context/PluginContext";
import { getPluginCommands, openPlugin } from "@/lib/plugins";
export function PluginContextMenuItems({
menu,
sessionId,
connectionId,
disabled = false,
}: {
menu: "terminal" | "connection";
sessionId?: string | null;
connectionId?: string;
disabled?: boolean;
}) {
const { plugins, locked } = usePlugins();
const commands = getPluginCommands(plugins, menu);
if (!commands.length) return null;
return (
<>
<ContextMenuSeparator />
{commands.map((command) => (
<ContextMenuItem
key={`${command.pluginId}:${command.id}`}
disabled={disabled || locked}
onClick={() =>
openPlugin({
pluginId: command.pluginId,
commandId: command.id,
sessionId: menu === "connection" ? null : sessionId,
connectionId,
})
}
>
<Puzzle className="mr-2 size-3.5 text-muted-foreground" />
{command.title}
</ContextMenuItem>
))}
</>
);
}
@@ -0,0 +1,100 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { writeClipboardText } from "@/lib/clipboard";
import { pluginApi } from "@/lib/plugins";
import type { PluginDiagnosticsSnapshot } from "@/types/plugins";
import { PluginDiagnostics } from "./PluginDiagnostics";
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
vi.mock("@/lib/plugins", () => ({
pluginApi: { diagnostics: vi.fn(), stopBackend: vi.fn(), clearLogs: vi.fn() },
}));
vi.mock("@/lib/clipboard", () => ({ writeClipboardText: vi.fn() }));
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
const props = {
pluginId: "example.sdk",
name: "SDK",
enabled: true,
native: true,
locked: false,
disabled: false,
};
const running: PluginDiagnosticsSnapshot = {
status: "running",
version: "1.0.0",
lastError: "Example error",
logs: [
{
timestamp: 1000,
version: "1.0.0",
level: "error",
source: "plugin",
message: "<script>unsafe text</script>",
},
],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(pluginApi.diagnostics).mockResolvedValue(running);
});
afterEach(cleanup);
describe("plugin diagnostics", () => {
it("shows native state, stops without disabling and refreshes the state", async () => {
render(<PluginDiagnostics {...props} />);
await screen.findByText("plugins.diagnostics.status.running");
vi.mocked(pluginApi.stopBackend).mockResolvedValue();
vi.mocked(pluginApi.diagnostics).mockResolvedValue({ ...running, status: "stopped" });
fireEvent.click(screen.getByText("plugins.diagnostics.stop"));
await screen.findByText("plugins.diagnostics.status.stopped");
expect(pluginApi.stopBackend).toHaveBeenCalledWith("example.sdk");
expect((screen.getByText("plugins.diagnostics.stop") as HTMLButtonElement).disabled).toBe(true);
});
it("renders logs as text and supports copy, refresh and clearing", async () => {
render(<PluginDiagnostics {...props} />);
await screen.findByText("plugins.diagnostics.status.running");
fireEvent.click(screen.getByText("plugins.diagnostics.logs"));
const region = await screen.findByRole("region", { name: "plugins.diagnostics.logs" });
await waitFor(() => expect(region.textContent).toContain("<script>unsafe text</script>"));
expect(region.querySelector("script")).toBeNull();
vi.mocked(writeClipboardText).mockResolvedValue();
fireEvent.click(screen.getByText("plugins.diagnostics.copy"));
await waitFor(() =>
expect(writeClipboardText).toHaveBeenCalledWith(
expect.stringContaining("[error] [plugin] v1.0.0"),
),
);
await waitFor(() =>
expect((screen.getByText("plugins.diagnostics.clear") as HTMLButtonElement).disabled).toBe(
false,
),
);
vi.mocked(pluginApi.clearLogs).mockResolvedValue();
vi.mocked(pluginApi.diagnostics).mockResolvedValue({ ...running, lastError: null, logs: [] });
fireEvent.click(screen.getByText("plugins.diagnostics.clear"));
await screen.findByText("plugins.diagnostics.empty");
expect(pluginApi.clearLogs).toHaveBeenCalledWith("example.sdk");
const calls = vi.mocked(pluginApi.diagnostics).mock.calls.length;
fireEvent.click(screen.getByText("plugins.diagnostics.refresh"));
await waitFor(() => expect(vi.mocked(pluginApi.diagnostics).mock.calls.length).toBe(calls + 1));
});
it("discards a pending response after locking and distinguishes UI plugins", async () => {
let resolve!: (value: PluginDiagnosticsSnapshot) => void;
vi.mocked(pluginApi.diagnostics).mockReturnValue(
new Promise((done) => {
resolve = done;
}),
);
const { rerender } = render(<PluginDiagnostics {...props} />);
rerender(<PluginDiagnostics {...props} locked />);
await act(async () => {
resolve(running);
});
expect(screen.queryByText("Example error")).toBeNull();
expect(screen.queryByText("plugins.diagnostics.status.running")).toBeNull();
rerender(<PluginDiagnostics {...props} native={false} />);
expect(screen.getByText("plugins.diagnostics.status.uiOnly")).toBeTruthy();
expect(screen.queryByText("plugins.diagnostics.stop")).toBeNull();
});
});
@@ -0,0 +1,176 @@
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 {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { writeClipboardText } from "@/lib/clipboard";
import { getErrorMessage } from "@/lib/errors";
import { pluginApi } from "@/lib/plugins";
import type { PluginDiagnosticsSnapshot } from "@/types/plugins";
export function formatPluginLogs(snapshot: PluginDiagnosticsSnapshot) {
return snapshot.logs
.map(
(entry) =>
`${new Date(entry.timestamp).toISOString()} [${entry.level}] [${entry.source}] v${entry.version} ${entry.message}`,
)
.join("\n");
}
export function PluginDiagnostics({
pluginId,
name,
enabled,
native,
locked,
disabled,
}: {
pluginId: string;
name: string;
enabled: boolean;
native: boolean;
locked: boolean;
disabled: boolean;
}) {
const { t } = useTranslation();
const [snapshot, setSnapshot] = useState<PluginDiagnosticsSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const refresh = useRef<() => Promise<void>>(async () => {});
useEffect(() => {
const current = ++generation.current;
setSnapshot(null);
setError(null);
if (locked) {
setOpen(false);
return;
}
let pending = false;
const read = async () => {
if (pending || generation.current !== current) return;
pending = true;
try {
const result = await pluginApi.diagnostics(pluginId);
if (generation.current === current) {
setSnapshot(result);
setError(null);
}
} catch (error) {
if (generation.current === current) setError(getErrorMessage(error));
} finally {
pending = false;
}
};
refresh.current = read;
void read();
const timer = window.setInterval(() => void read(), open ? 1000 : 2500);
return () => {
++generation.current;
window.clearInterval(timer);
};
}, [pluginId, locked, open]);
const action = useCallback(async (operation: () => Promise<unknown>) => {
setBusy(true);
try {
await operation();
await refresh.current();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setBusy(false);
}
}, []);
const status = !enabled ? "disabled" : !native ? "uiOnly" : snapshot?.status;
const blocked = busy || locked || disabled;
const text = snapshot ? formatPluginLogs(snapshot) : "";
return (
<div className="space-y-2 border-t pt-3">
<div className="flex flex-wrap items-center gap-2">
{status && (
<Badge variant={status === "error" ? "destructive" : "outline"}>
{t(`plugins.diagnostics.status.${status}`)}
</Badge>
)}
<Button size="sm" variant="ghost" disabled={locked} onClick={() => setOpen(true)}>
{t("plugins.diagnostics.logs")}
</Button>
{native && (
<Button
size="sm"
variant="outline"
disabled={blocked || !snapshot || !["starting", "running"].includes(snapshot.status)}
onClick={() => void action(() => pluginApi.stopBackend(pluginId))}
>
{t("plugins.diagnostics.stop")}
</Button>
)}
</div>
{snapshot?.lastError && !locked && (
<p role="alert" className="break-words text-xs text-destructive">
{snapshot.lastError}
</p>
)}
{error && (
<p role="alert" className="break-words text-xs text-destructive">
{error}
</p>
)}
<Dialog open={open && !locked} onOpenChange={setOpen}>
<DialogContent className="flex max-h-[85vh] max-w-3xl flex-col">
<DialogHeader>
<DialogTitle>{t("plugins.diagnostics.title", { name })}</DialogTitle>
<DialogDescription>{t("plugins.diagnostics.description")}</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={blocked}
onClick={() => void refresh.current()}
>
{t("plugins.diagnostics.refresh")}
</Button>
<Button
size="sm"
variant="outline"
disabled={blocked || !text}
onClick={() => void action(() => writeClipboardText(text))}
>
{t("plugins.diagnostics.copy")}
</Button>
<Button
size="sm"
variant="outline"
disabled={blocked || !snapshot}
onClick={() => void action(() => pluginApi.clearLogs(pluginId))}
>
{t("plugins.diagnostics.clear")}
</Button>
</div>
<section aria-label={t("plugins.diagnostics.logs")} className="min-h-0 overflow-auto">
<pre className="min-h-32 whitespace-pre-wrap break-words rounded-md border bg-muted p-3 text-xs">
{text || t("plugins.diagnostics.empty")}
</pre>
</section>
{error && (
<p role="alert" className="break-words text-sm text-destructive">
{error}
</p>
)}
</DialogContent>
</Dialog>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { usePlugins } from "@/context/PluginContext";
import { getErrorMessage } from "@/lib/errors";
import { createPluginBridge } from "@/lib/pluginBridge";
import { activeManifest, parsePluginPanelId, pluginApi } from "@/lib/plugins";
import type { PluginScope } from "@/types/plugins";
const THEME_VARIABLES = [
"--background",
"--foreground",
"--primary",
"--primary-foreground",
"--muted",
"--muted-foreground",
"--border",
"--destructive",
"--df-bg",
"--df-fg",
];
function theme() {
const style = getComputedStyle(document.documentElement);
return Object.fromEntries(
THEME_VARIABLES.map((key) => [key, style.getPropertyValue(key).trim()]),
);
}
export function PluginPanel({
activityId,
sessionId,
}: {
activityId: string;
sessionId: string | null;
}) {
const { t } = useTranslation();
const { plugins, generation, locked, loaded, openIntent } = usePlugins();
const panelId = parsePluginPanelId(activityId);
const plugin = plugins.find((plugin) => plugin.id === panelId?.pluginId);
const manifest = plugin && activeManifest(plugin);
const panel = manifest?.contributions.panels.find(
(panel) => panel.id === panelId?.panelId,
);
const intentPanelId =
openIntent?.panelId ??
manifest?.contributions.commands.find(
(command) => command.id === openIntent?.commandId,
)?.panel;
const scopedSessionId =
openIntent?.pluginId === plugin?.id &&
intentPanelId === panel?.id &&
openIntent?.sessionId !== undefined
? openIntent.sessionId
: sessionId;
const [scope, setScope] = useState<PluginScope | null>(null);
const [error, setError] = useState<string | null>(null);
const [retry, setRetry] = useState(0);
const frame = useRef<HTMLIFrameElement>(null);
const enabled = Boolean(plugin?.enabled && panel && !locked);
const pluginId = plugin?.id;
const version = plugin?.activeVersion;
// Permission changes and renewal must replace the backend-issued scope.
// biome-ignore lint/correctness/useExhaustiveDependencies: version, generation and retry invalidate scopes even if the plugin remains enabled.
useEffect(() => {
setScope(null);
setError(null);
if (!enabled || !pluginId) return;
let disposed = false;
let token: string | undefined;
void pluginApi
.createScope(pluginId, scopedSessionId)
.then((scope) => {
token = scope.token;
if (disposed) void pluginApi.closeScope(scope.token).catch(() => {});
else setScope(scope);
})
.catch((error) => {
if (!disposed) setError(getErrorMessage(error));
});
const refreshTimer = window.setTimeout(
() => setRetry((value) => value + 1),
25 * 60 * 1000,
);
return () => {
disposed = true;
window.clearTimeout(refreshTimer);
if (token) void pluginApi.closeScope(token).catch(() => {});
};
}, [enabled, pluginId, version, generation, scopedSessionId, retry]);
const entry = panel?.entry;
useEffect(() => {
const iframe = frame.current;
if (!scope || !iframe || !entry) return;
const bridge = createPluginBridge(iframe, scope.token, () => ({
pluginId: scope.pluginId,
version: scope.version,
theme: theme(),
}));
const observer = new MutationObserver(() => bridge.updateContext());
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
// Register the bridge before the document starts loading and sends its ready message.
iframe.src = `${convertFileSrc("", "nyaterm-plugin")}${scope.token}/${entry.split("/").map(encodeURIComponent).join("/")}`;
return () => {
bridge.dispose();
observer.disconnect();
};
}, [scope, entry]);
if (!enabled)
return (
<output className="block p-4 text-sm text-muted-foreground">
{!loaded
? t("plugins.loading")
: locked
? t("plugins.locked")
: t("plugins.unavailable")}
</output>
);
if (error)
return (
<div className="space-y-3 p-4" role="alert">
<p className="break-words text-sm text-destructive">{error}</p>
<Button
variant="outline"
onClick={() => setRetry((value) => value + 1)}
>
{t("plugins.retry")}
</Button>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col">
<div className="border-b px-3 py-2 text-sm font-medium">
{panel?.title}
</div>
{scope ? (
<iframe
ref={frame}
title={panel?.title}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
className="min-h-0 flex-1 w-full border-0"
/>
) : (
<div className="p-4 text-sm text-muted-foreground">
{t("plugins.loading")}
</div>
)}
</div>
);
}
+376
View File
@@ -0,0 +1,376 @@
import { open } from "@tauri-apps/plugin-dialog";
import { Puzzle } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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";
export function PluginsPanel({ sessionId }: { sessionId: string | null }) {
const { t } = useTranslation();
const { plugins, loaded, error, refresh, locked } = usePlugins();
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<{
path: string;
package: PluginPackagePreview;
} | null>(null);
const [grant, setGrant] = useState<{
plugin: InstalledPlugin;
permissions: string[];
} | null>(null);
const [remove, setRemove] = useState<InstalledPlugin | null>(null);
const action = async (operation: () => Promise<unknown>) => {
setBusy(true);
try {
await operation();
await refresh();
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setBusy(false);
}
};
const inspect = () =>
void action(async () => {
const path = await open({
multiple: false,
directory: false,
filters: [{ name: "NyaTerm Plugin", extensions: ["nyap"] }],
});
if (typeof path === "string") setPreview({ path, package: await pluginApi.inspect(path) });
});
const permissionLabel = (permission: string) =>
permission.startsWith("network:")
? t("plugins.permissions.network", { origin: permission.slice(8) })
: t(`plugins.permissions.${permission.split(".").join("_")}`);
const grantManifest = grant && activeManifest(grant.plugin);
return (
<div className="h-full min-h-0 overflow-auto p-3 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="flex items-center gap-2 text-sm font-semibold">
<Puzzle size={16} />
{t("plugins.title")}
</h3>
<Button size="sm" disabled={busy || locked} onClick={inspect}>
{t("plugins.install")}
</Button>
</div>
<p className="text-sm text-muted-foreground">{t("plugins.description")}</p>
{error && (
<div role="alert" className="space-y-2 text-sm text-destructive">
<p>{error}</p>
<Button variant="outline" onClick={() => void refresh()}>
{t("plugins.retry")}
</Button>
</div>
)}
{!loaded && <p className="text-sm text-muted-foreground">{t("plugins.loading")}</p>}
{loaded && !plugins.length && !error && (
<p className="rounded-md border p-5 text-sm text-muted-foreground">
{t("plugins.noInstalled")}
</p>
)}
{plugins.map((plugin) => {
const manifest = activeManifest(plugin);
if (!manifest) return null;
return (
<div key={plugin.id} className="space-y-3 rounded-lg border bg-card p-4">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{manifest.name}</h3>
<Badge variant={plugin.enabled ? "default" : "secondary"}>
{t(plugin.enabled ? "plugins.enabled" : "plugins.disabled")}
</Badge>
{manifest.backend && <Badge variant="outline">{t("plugins.native")}</Badge>}
</div>
<p className="break-words text-sm text-muted-foreground">{manifest.description}</p>
<p className="break-all text-xs text-muted-foreground">
{plugin.id} · {t("plugins.publisher", { name: manifest.publisher })}
</p>
<div className="flex flex-wrap items-center gap-2">
<Select
value={plugin.activeVersion}
disabled={busy || locked}
onValueChange={(version) =>
void action(() => pluginApi.activate(plugin.id, version))
}
>
<SelectTrigger className="w-36" aria-label={t("plugins.version")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.keys(plugin.versions).map((version) => (
<SelectItem key={version} value={version}>
{version}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
disabled={busy || locked}
onClick={() =>
plugin.enabled
? void action(() =>
pluginApi.configure(
plugin.id,
plugin.activeVersion,
false,
plugin.grantedPermissions,
),
)
: setGrant({
plugin,
permissions: [...plugin.grantedPermissions],
})
}
>
{t(plugin.enabled ? "plugins.disable" : "plugins.enable")}
</Button>
<Button
variant="ghost"
disabled={busy || locked}
onClick={() =>
setGrant({
plugin,
permissions: [...plugin.grantedPermissions],
})
}
>
{t("plugins.permissionsTitle")}
</Button>
<Button
variant="ghost"
className="text-destructive"
disabled={busy || locked}
onClick={() => setRemove(plugin)}
>
{t("plugins.uninstall")}
</Button>
</div>
<PluginDiagnostics
pluginId={plugin.id}
key={`${plugin.id}:${plugin.activeVersion}:${plugin.enabled}`}
name={manifest.name}
enabled={plugin.enabled}
native={Boolean(manifest.backend)}
locked={locked}
disabled={busy}
/>
{plugin.enabled &&
(manifest.contributions.panels.length > 0 ||
manifest.contributions.commands.some((command) => command.method)) && (
<div className="space-y-2 border-t pt-3">
{manifest.contributions.panels.map((panel) => (
<Button
key={panel.id}
className="w-full justify-start whitespace-normal text-left"
variant="outline"
disabled={busy || locked}
onClick={() =>
openPlugin({
pluginId: plugin.id,
panelId: panel.id,
sessionId,
})
}
>
{panel.title}
</Button>
))}
{manifest.contributions.commands
.filter((command) => command.method)
.map((command) => (
<Button
key={command.id}
className="w-full justify-start whitespace-normal text-left"
variant="outline"
disabled={busy || locked}
onClick={() =>
openPlugin({
pluginId: plugin.id,
commandId: command.id,
sessionId,
})
}
>
{command.title}
</Button>
))}
</div>
)}
</div>
);
})}
<Dialog open={preview !== null} onOpenChange={(open) => !open && !busy && setPreview(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("plugins.reviewPackage")}</DialogTitle>
<DialogDescription>{t("plugins.reviewDescription")}</DialogDescription>
</DialogHeader>
{preview && (
<div className="space-y-3 text-sm">
<p className="font-medium">
{preview.package.manifest.name} {preview.package.manifest.version}
</p>
<p>{preview.package.manifest.description}</p>
<p className="break-all text-muted-foreground">
{t("plugins.publisher", {
name: preview.package.manifest.publisher,
})}
</p>
<p className="text-xs text-muted-foreground">{t("plugins.publisherUnverified")}</p>
<div className="space-y-1">
<p className="font-medium">{t("plugins.permissionsTitle")}</p>
{preview.package.manifest.permissions.map((permission) => (
<p key={permission}>{permissionLabel(permission)}</p>
))}
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setPreview(null)}>
{t("plugins.cancel")}
</Button>
<Button
disabled={busy || locked}
onClick={() =>
preview &&
void action(async () => {
await pluginApi.install(preview.path, preview.package.digest);
setPreview(null);
toast.success(t("plugins.installed"));
})
}
>
{t("plugins.install")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={grant !== null} onOpenChange={(open) => !open && !busy && setGrant(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("plugins.permissionsTitle")}</DialogTitle>
<DialogDescription>
{t("plugins.permissionsDescription", {
name: grantManifest?.name,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{grantManifest?.permissions.map((permission) => (
<label key={permission} className="flex items-start gap-3 text-sm">
<Checkbox
checked={grant?.permissions.includes(permission)}
disabled={busy}
onCheckedChange={(checked) =>
setGrant(
(current) =>
current && {
...current,
permissions:
checked === true
? [...new Set([...current.permissions, permission])]
: current.permissions.filter((value) => value !== permission),
},
)
}
/>
<span>{permissionLabel(permission)}</span>
</label>
))}
{grantManifest?.backend && (
<p className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
{t("plugins.nativeTrust")}
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setGrant(null)}>
{t("plugins.cancel")}
</Button>
<Button
disabled={
busy ||
locked ||
Boolean(grantManifest?.backend && !grant?.permissions.includes("native"))
}
onClick={() =>
grant &&
void action(async () => {
await pluginApi.configure(
grant.plugin.id,
grant.plugin.activeVersion,
true,
grant.permissions,
);
setGrant(null);
})
}
>
{t("plugins.saveAndEnable")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={remove !== null} onOpenChange={(open) => !open && setRemove(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("plugins.uninstallTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("plugins.uninstallDescription", {
name: remove && activeManifest(remove)?.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("plugins.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
remove &&
void action(async () => {
await pluginApi.uninstall(remove.id);
setRemove(null);
})
}
>
{t("plugins.uninstall")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -20,6 +20,7 @@ import {
MdTravelExplore,
} from "react-icons/md";
import { PiRecordFill } from "react-icons/pi";
import { PluginContextMenuItems } from "@/components/plugins/PluginContextMenuItems";
import { useTerminalAppSettings } from "@/context/AppContext";
import { resolveDisplayKeys } from "@/hooks/useShortcutMap";
import { openAIAssistant } from "@/lib/aiEvents";
@@ -500,6 +501,7 @@ export default function TerminalContextMenu({
{dk("terminal.selectAll")}
</ContextMenuShortcut>
</ContextMenuItem>
<PluginContextMenuItems menu="terminal" sessionId={sessionId} disabled={appLocked} />
</ContextMenuContent>
</ContextMenu>
<TranslationDialog
+113
View File
@@ -0,0 +1,113 @@
import { listen } from "@tauri-apps/api/event";
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { getErrorMessage } from "@/lib/errors";
import {
PLUGIN_OPEN_EVENT,
pluginApi,
type PluginOpenIntent,
} from "@/lib/plugins";
import type { InstalledPlugin } from "@/types/plugins";
type PluginContextValue = {
plugins: InstalledPlugin[];
loaded: boolean;
error: string | null;
generation: number;
locked: boolean;
openIntent: PluginOpenIntent | null;
refresh: () => Promise<void>;
};
const PluginContext = createContext<PluginContextValue>({
plugins: [],
loaded: false,
error: null,
generation: 0,
locked: false,
openIntent: null,
refresh: async () => {},
});
export const usePlugins = () => useContext(PluginContext);
export function PluginProvider({ children }: { children: React.ReactNode }) {
const [plugins, setPlugins] = useState<InstalledPlugin[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
const [generation, setGeneration] = useState(0);
const [locked, setLocked] = useState(false);
const [openIntent, setOpenIntent] = useState<PluginOpenIntent | null>(null);
const sequence = useRef(0);
const mounted = useRef(false);
const refresh = useCallback(async () => {
const request = ++sequence.current;
try {
const next = await pluginApi.list();
if (!mounted.current || request !== sequence.current) return;
setPlugins(next);
setError(null);
} catch (error) {
if (!mounted.current || request !== sequence.current) return;
setError(getErrorMessage(error));
} finally {
if (mounted.current && request === sequence.current) setLoaded(true);
}
}, []);
useEffect(() => {
mounted.current = true;
const disposers: (() => void)[] = [];
let disposed = false;
const register = (promise: Promise<() => void>) => {
void promise
.then((dispose) => (disposed ? dispose() : disposers.push(dispose)))
.catch(() => {});
};
register(
listen("plugins-changed", () => {
setGeneration((value) => value + 1);
void refresh();
}),
);
register(
listen<{ locked: boolean }>("app-lock-state-changed", (event) => {
setLocked(event.payload.locked);
setGeneration((value) => value + 1);
}),
);
void refresh();
const open = (event: Event) =>
setOpenIntent((event as CustomEvent<PluginOpenIntent>).detail);
window.addEventListener(PLUGIN_OPEN_EVENT, open);
return () => {
mounted.current = false;
disposed = true;
sequence.current += 1;
for (const dispose of disposers) dispose();
window.removeEventListener(PLUGIN_OPEN_EVENT, open);
};
}, [refresh]);
return (
<PluginContext.Provider
value={{
plugins,
loaded,
error,
generation,
locked,
openIntent,
refresh,
}}
>
{children}
</PluginContext.Provider>
);
}
+73 -1
View File
@@ -16,9 +16,12 @@ import {
MdOutlineStickyNote2,
MdSend,
MdSettings,
MdExtension,
} from "react-icons/md";
import { PiRecordFill } from "react-icons/pi";
import { SiDocker, SiNvidia } from "react-icons/si";
import { usePlugins } from "@/context/PluginContext";
import { activeManifest, getPluginPanels, pluginPanelId } from "@/lib/plugins";
import type { ActivityBarItem } from "@/components/layout/ActivityBar";
import {
ACTIVITY_BAR_ITEM_IDS,
@@ -105,6 +108,14 @@ function normalizeActivityBarState(uiConfig: UiConfig): Partial<UiConfig> | null
layout.left_bottom = insertBeforeOrPush(layout.left_bottom, "settings", "syncBackupHistory");
seen.add("syncBackupHistory");
}
if (!seen.has("plugins")) {
layout.left_bottom = insertBeforeOrPush(
layout.left_bottom,
"settings",
"plugins",
);
seen.add("plugins");
}
if (!seen.has("notes")) {
layout.left_top = insertAfter(layout.left_top, "fileExplorer", "notes");
seen.add("notes");
@@ -245,6 +256,8 @@ export function useActivityBarController({
setIsLocked,
t,
}: UseActivityBarControllerOptions) {
const { plugins, openIntent } = usePlugins();
const pluginPanels = useMemo(() => getPluginPanels(plugins), [plugins]);
const itemRegistry = useMemo<Record<string, { icon: ReactNode; tooltip: string }>>(
() => ({
fileExplorer: { icon: <FaRegFolder />, tooltip: t("panel.fileExplorer") },
@@ -253,6 +266,13 @@ export function useActivityBarController({
securityAuth: { icon: <LuKeyRound />, tooltip: t("securityAuth.title") },
syncBackupHistory: { icon: <MdBackup />, tooltip: t("panel.syncBackupHistory") },
settings: { icon: <MdSettings />, tooltip: t("settings.title") },
plugins: { icon: <MdExtension />, tooltip: t("plugins.title") },
...Object.fromEntries(
pluginPanels.map((panel) => [
panel.activityId,
{ icon: <MdExtension />, tooltip: panel.title },
]),
),
savedConnections: { icon: <BiServer />, tooltip: t("panel.savedConnections") },
aiAssistant: { icon: <MdAutoAwesome />, tooltip: t("ai.title") },
activeSessions: { icon: <MdLink />, tooltip: t("panel.activeSessions") },
@@ -270,10 +290,62 @@ export function useActivityBarController({
},
lock: { icon: <MdLock />, tooltip: t("statusBar.lock") },
}),
[recordingSessions, t],
[recordingSessions, t, pluginPanels],
);
const layout = uiConfig.activity_bar_layout;
useEffect(() => {
const allIds = new Set(
ACTIVITY_LAYOUT_ZONES.flatMap((zone) => layout[zone]),
);
if (!pluginPanels.some((panel) => !allIds.has(panel.activityId))) return;
updateUi((prev) => {
const existing = new Set(
ACTIVITY_LAYOUT_ZONES.flatMap((zone) => prev.activity_bar_layout[zone]),
);
const added = pluginPanels
.map((panel) => panel.activityId)
.filter((id) => !existing.has(id));
return added.length
? {
activity_bar_layout: {
...prev.activity_bar_layout,
right_top: [...prev.activity_bar_layout.right_top, ...added],
},
}
: {};
});
}, [layout, pluginPanels, updateUi]);
useEffect(() => {
if (!openIntent) return;
const plugin = plugins.find(
(plugin) => plugin.id === openIntent.pluginId && plugin.enabled,
);
const manifest = plugin && activeManifest(plugin);
const panelId =
openIntent.panelId ??
manifest?.contributions.commands.find(
(command) => command.id === openIntent.commandId,
)?.panel;
if (
!panelId ||
!manifest?.contributions.panels.some((panel) => panel.id === panelId)
)
return;
const id = pluginPanelId(openIntent.pluginId, panelId);
updateUi((prev) => {
const side = getItemSide(id, prev.activity_bar_layout) ?? "right";
const list = side === "left" ? "left_open_panels" : "right_open_panels";
return {
...(side === "left"
? { active_left_panel: id }
: { active_right_panel: id }),
[list]: [...new Set([...(prev[list] ?? []), id])],
activity_bar_layout: showActivityBarItem(prev.activity_bar_layout, id),
};
});
}, [openIntent, plugins, updateUi]);
useEffect(() => {
if (!normalizeActivityBarState(uiConfig)) return;
+63
View File
@@ -1643,6 +1643,69 @@
"username": "Username",
"usernamePlaceholder": "e.g. root"
},
"plugins": {
"allowOnce": "Allow once",
"approvalDescription": "{{name}} requests access to the selected session.",
"approvalTitle": "Approve plugin operation",
"cancel": "Cancel",
"commandResult": "Result from the plugin command.",
"deny": "Deny",
"description": "Install local plugin packages and choose the permissions each plugin can use. Plugin changes apply immediately.",
"diagnostics": {
"clear": "Clear logs",
"copy": "Copy logs",
"description": "Logs are kept in memory for this app session. Stopping the backend resets its process; the next command starts it again.",
"empty": "No logs yet.",
"logs": "Logs",
"refresh": "Refresh",
"status": {
"disabled": "Disabled",
"error": "Failed",
"idle": "Not started",
"running": "Running",
"starting": "Starting",
"stopped": "Stopped",
"uiOnly": "UI only"
},
"stop": "Stop backend",
"title": "{{name}} logs"
},
"disable": "Disable",
"disabled": "Disabled",
"enable": "Enable",
"enabled": "Enabled",
"install": "Install package",
"installed": "Plugin installed. Review its permissions to enable it.",
"loading": "Loading plugins…",
"locked": "Unlock NyaTerm to use plugins.",
"native": "Native backend",
"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": {
"filesystem_read": "Request text file reads in the selected session",
"native": "Run this plugin’s native executable",
"network": "Make HTTPS requests to {{origin}}",
"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",
"terminal_read": "Read recent output from the selected terminal"
},
"permissionsDescription": "Choose which capabilities {{name}} may use. Terminal commands and file reads require approval for each operation.",
"permissionsTitle": "Permissions",
"publisher": "Publisher: {{name}}",
"publisherUnverified": "This publisher name is declared by the package and has not been verified.",
"retry": "Retry",
"reviewDescription": "Check the publisher and requested permissions. Installation leaves the plugin disabled until you enable it.",
"reviewPackage": "Review plugin package",
"running": "Running plugin command…",
"saveAndEnable": "Save and enable",
"title": "Plugins",
"unavailable": "This plugin panel is disabled or unavailable. Its layout is preserved.",
"uninstall": "Uninstall",
"uninstallDescription": "Remove {{name}} and all its installed versions. Saved panel positions and plugin settings are retained for reinstallation.",
"uninstallTitle": "Uninstall plugin?",
"version": "Active version"
},
"processManager": {
"commandCopied": "Command copied",
"confirmSignalDesc": "Send {{signal}} to PID {{pid}}?\n{{command}}",
+63
View File
@@ -1641,6 +1641,69 @@
"username": "사용자 이름",
"usernamePlaceholder": "예: root"
},
"plugins": {
"allowOnce": "이번만 허용",
"approvalDescription": "{{name}}에서 선택한 세션에 대한 접근을 요청합니다.",
"approvalTitle": "플러그인 작업 승인",
"cancel": "취소",
"commandResult": "플러그인 명령의 결과입니다.",
"deny": "거부",
"description": "로컬 플러그인 패키지를 설치하고 각 플러그인의 권한을 선택하세요. 변경 사항은 즉시 적용됩니다.",
"diagnostics": {
"clear": "로그 지우기",
"copy": "로그 복사",
"description": "로그는 이번 앱 실행 동안 메모리에만 보관됩니다. 백엔드를 중지하면 프로세스가 종료되고 다음 명령에서 다시 시작됩니다.",
"empty": "로그가 없습니다.",
"logs": "로그 보기",
"refresh": "새로 고침",
"status": {
"disabled": "비활성화",
"error": "오류",
"idle": "시작 전",
"running": "실행 중",
"starting": "시작 중",
"stopped": "중지됨",
"uiOnly": "UI 전용"
},
"stop": "백엔드 중지",
"title": "{{name}} 로그"
},
"disable": "비활성화",
"disabled": "비활성화됨",
"enable": "활성화",
"enabled": "활성화됨",
"install": "패키지 설치",
"installed": "플러그인이 설치되었습니다. 권한을 검토한 후 활성화하세요.",
"loading": "플러그인 로딩 중…",
"locked": "플러그인을 사용하려면 NyaTerm 잠금을 해제하세요.",
"native": "네이티브 백엔드",
"nativeTrust": "네이티브 백엔드는 운영체제 사용자 권한으로 실행됩니다. 호스트 권한과 별개로 파일, 네트워크 및 프로그램에 접근할 수 있습니다. 신뢰할 수 있는 게시자의 네이티브 코드만 활성화하세요.",
"noInstalled": "설치된 플러그인이 없습니다.",
"permissions": {
"filesystem_read": "선택한 세션의 텍스트 파일 읽기 요청",
"native": "이 플러그인의 네이티브 실행 파일 실행",
"network": "{{origin}}에 HTTPS 요청 보내기",
"session_read": "선택한 세션의 이름 및 유형 읽기",
"storage": "이 플러그인의 설정 저장",
"terminal_execute": "선택한 터미널에서 명령 실행 요청",
"terminal_read": "선택한 터미널의 최근 출력 읽기"
},
"permissionsDescription": "{{name}}에서 사용할 기능을 선택하세요. 터미널 명령과 파일 읽기는 매번 승인이 필요합니다.",
"permissionsTitle": "권한",
"publisher": "게시자: {{name}}",
"publisherUnverified": "게시자 이름은 패키지에 선언된 정보이며 검증되지 않았습니다.",
"retry": "다시 시도",
"reviewDescription": "게시자와 요청 권한을 확인하세요. 설치 후 직접 활성화해야 합니다.",
"reviewPackage": "플러그인 패키지 검토",
"running": "플러그인 명령 실행 중…",
"saveAndEnable": "저장 및 활성화",
"title": "플러그인",
"unavailable": "이 플러그인 패널은 비활성화되었거나 사용할 수 없습니다. 레이아웃은 유지됩니다.",
"uninstall": "제거",
"uninstallDescription": "{{name}} 및 설치된 모든 버전을 제거합니다. 다시 설치할 때 사용할 패널 위치와 설정은 유지됩니다.",
"uninstallTitle": "플러그인을 제거할까요?",
"version": "현재 버전"
},
"processManager": {
"commandCopied": "명령이 복사되었습니다",
"confirmSignalDesc": "PID {{pid}}에 {{signal}}을(를) 보낼까요?\n{{command}}",
+63
View File
@@ -1641,6 +1641,69 @@
"username": "用户名",
"usernamePlaceholder": "例如:root"
},
"plugins": {
"allowOnce": "仅允许本次",
"approvalDescription": "{{name}} 请求访问所选会话。",
"approvalTitle": "批准插件操作",
"cancel": "取消",
"commandResult": "插件命令返回的结果。",
"deny": "拒绝",
"description": "安装本地插件包,并选择各插件可使用的权限。插件更改立即生效。",
"diagnostics": {
"clear": "清空日志",
"copy": "复制日志",
"description": "日志仅保留在本次应用运行的内存中。停止后台会结束进程,下次执行命令时会重新启动。",
"empty": "暂无日志。",
"logs": "查看日志",
"refresh": "刷新",
"status": {
"disabled": "已禁用",
"error": "运行异常",
"idle": "未启动",
"running": "运行中",
"starting": "启动中",
"stopped": "已停止",
"uiOnly": "仅界面插件"
},
"stop": "停止后台",
"title": "{{name}} 的日志"
},
"disable": "停用",
"disabled": "已停用",
"enable": "启用",
"enabled": "已启用",
"install": "安装插件包",
"installed": "插件已安装,请检查权限后启用。",
"loading": "正在加载插件…",
"locked": "解锁 NyaTerm 后即可使用插件。",
"native": "原生后端",
"nativeTrust": "原生后端以当前系统用户权限运行,可独立访问文件、网络和程序,宿主权限无法限制这些操作。请仅启用可信作者提供的原生代码。",
"noInstalled": "尚未安装插件。",
"permissions": {
"filesystem_read": "请求读取所选会话中的文本文件",
"native": "运行此插件的原生程序",
"network": "向 {{origin}} 发起 HTTPS 请求",
"session_read": "读取所选会话的名称和类型",
"storage": "保存此插件自己的设置",
"terminal_execute": "请求在所选终端执行命令",
"terminal_read": "读取所选终端的近期输出"
},
"permissionsDescription": "选择 {{name}} 可使用的能力。终端命令和文件读取仍需逐次批准。",
"permissionsTitle": "权限",
"publisher": "作者:{{name}}",
"publisherUnverified": "作者名称由插件包声明,尚未经过验证。",
"retry": "重试",
"reviewDescription": "请检查作者和申请的权限。安装后插件保持停用,需手动启用。",
"reviewPackage": "检查插件包",
"running": "正在运行插件命令…",
"saveAndEnable": "保存并启用",
"title": "插件",
"unavailable": "此插件面板已停用或暂不可用,布局位置已保留。",
"uninstall": "卸载",
"uninstallDescription": "移除 {{name}} 及其全部已安装版本。面板位置和插件设置将保留,供重新安装时使用。",
"uninstallTitle": "卸载插件?",
"version": "当前版本"
},
"processManager": {
"commandCopied": "命令已复制",
"confirmSignalDesc": "向 PID {{pid}} 发送 {{signal}}?\n{{command}}",
+63
View File
@@ -1641,6 +1641,69 @@
"username": "使用者名稱",
"usernamePlaceholder": "例如:root"
},
"plugins": {
"allowOnce": "僅允許本次",
"approvalDescription": "{{name}} 要求存取所選工作階段。",
"approvalTitle": "核准外掛操作",
"cancel": "取消",
"commandResult": "外掛命令傳回的結果。",
"deny": "拒絕",
"description": "安裝本機外掛套件,並選擇各外掛可使用的權限。外掛變更立即生效。",
"diagnostics": {
"clear": "清空日誌",
"copy": "複製日誌",
"description": "日誌僅保留在本次應用程式執行的記憶體中。停止後端會結束程序,下次執行命令時會重新啟動。",
"empty": "暫無日誌。",
"logs": "檢視日誌",
"refresh": "重新整理",
"status": {
"disabled": "已停用",
"error": "執行異常",
"idle": "未啟動",
"running": "執行中",
"starting": "啟動中",
"stopped": "已停止",
"uiOnly": "僅介面外掛"
},
"stop": "停止後端",
"title": "{{name}} 的日誌"
},
"disable": "停用",
"disabled": "已停用",
"enable": "啟用",
"enabled": "已啟用",
"install": "安裝外掛套件",
"installed": "外掛已安裝,請檢查權限後啟用。",
"loading": "正在載入外掛…",
"locked": "解鎖 NyaTerm 後即可使用外掛。",
"native": "原生後端",
"nativeTrust": "原生後端以目前系統使用者權限執行,可獨立存取檔案、網路及程式,宿主權限無法限制這些操作。請僅啟用可信作者提供的原生程式碼。",
"noInstalled": "尚未安裝外掛。",
"permissions": {
"filesystem_read": "要求讀取所選工作階段中的文字檔案",
"native": "執行此外掛的原生程式",
"network": "向 {{origin}} 發出 HTTPS 要求",
"session_read": "讀取所選工作階段的名稱和類型",
"storage": "儲存此外掛自己的設定",
"terminal_execute": "要求在所選終端執行命令",
"terminal_read": "讀取所選終端的近期輸出"
},
"permissionsDescription": "選擇 {{name}} 可使用的能力。終端命令和檔案讀取仍需逐次核准。",
"permissionsTitle": "權限",
"publisher": "作者:{{name}}",
"publisherUnverified": "作者名稱由外掛套件宣告,尚未經過驗證。",
"retry": "重試",
"reviewDescription": "請檢查作者及要求的權限。安裝後外掛保持停用,需手動啟用。",
"reviewPackage": "檢查外掛套件",
"running": "正在執行外掛命令…",
"saveAndEnable": "儲存並啟用",
"title": "外掛",
"unavailable": "此外掛面板已停用或暫時無法使用,版面位置已保留。",
"uninstall": "解除安裝",
"uninstallDescription": "移除 {{name}} 及其所有已安裝版本。面板位置和外掛設定將保留,供重新安裝時使用。",
"uninstallTitle": "解除安裝外掛?",
"version": "目前版本"
},
"processManager": {
"commandCopied": "命令已複製",
"confirmSignalDesc": "向 PID {{pid}} 傳送 {{signal}}?\n{{command}}",
+5 -3
View File
@@ -1,5 +1,6 @@
import type { TerminalWindowNode } from "@/lib/tabWindows";
import { collectSessionPanes } from "@/lib/workspaceTabs";
import { parsePluginPanelId } from "@/lib/plugins";
import type {
ActivityBarLayout,
ActivityBarZone,
@@ -18,7 +19,7 @@ export const ACTIVITY_LAYOUT_ZONES = [
export const DEFAULT_ACTIVITY_BAR_LAYOUT: ActivityBarLayout = {
left_top: ["fileExplorer", "notes", "network", "securityAuth"],
left_bottom: ["syncBackupHistory", "settings"],
left_bottom: ["syncBackupHistory", "plugins", "settings"],
right_top: [
"savedConnections",
"aiAssistant",
@@ -43,6 +44,7 @@ export const ACTIVITY_BAR_ITEM_IDS = new Set<string>([
]);
export const ACTIVITY_BAR_PANEL_ITEM_IDS = new Set<string>([
"plugins",
"fileExplorer",
"notes",
"network",
@@ -84,7 +86,7 @@ export function normalizePanelOpenMode(
}
export function canUseFloatingPanel(id: string): boolean {
return ACTIVITY_BAR_PANEL_ITEM_IDS.has(id) && !NON_PANEL_IDS.has(id);
return (ACTIVITY_BAR_PANEL_ITEM_IDS.has(id) || Boolean(parsePluginPanelId(id))) && !NON_PANEL_IDS.has(id);
}
const MONITOR_PANEL_VISIBILITY: Record<string, (ui: UiConfig) => boolean> = {
@@ -295,7 +297,7 @@ export function getHiddenActivityItemsForSide(
): string[] {
const hidden = new Set(ui.activity_bar_layout.hidden_items ?? []);
return getActivityBarItemIdsForSide(ui.activity_bar_layout, side).filter(
(id) => itemIds.has(id) && hidden.has(id) && isActivityItemAvailable(id, ui),
(id) => (itemIds.has(id) || Boolean(parsePluginPanelId(id))) && hidden.has(id) && isActivityItemAvailable(id, ui),
);
}
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const calls = vi.hoisted(() => ({ host: vi.fn(), backend: vi.fn() }));
vi.mock("@/lib/plugins", () => ({
pluginApi: { hostCall: calls.host, backendCall: calls.backend },
}));
import { createPluginBridge } from "./pluginBridge";
describe("plugin bridge isolation", () => {
let frame: HTMLIFrameElement;
let bridge: ReturnType<typeof createPluginBridge>;
let post: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
calls.host.mockResolvedValue({ output: "hello" });
calls.backend.mockResolvedValue("done");
frame = document.createElement("iframe");
document.body.append(frame);
post = vi.spyOn(frame.contentWindow as Window, "postMessage");
bridge = createPluginBridge(frame, "fixed-token", () => ({
pluginId: "example.tools",
version: "1.0.0",
theme: {},
}));
});
afterEach(() => {
bridge.dispose();
frame.remove();
});
const message = (
frame: HTMLIFrameElement,
method: string,
origin = "null",
source: Window | null = frame.contentWindow,
) => {
window.dispatchEvent(
new MessageEvent("message", {
source,
origin,
data: {
type: "nyaterm-plugin-request",
id: "1",
method,
params: { lines: 20, token: "forged-token" },
},
}),
);
};
it("rejects other frames and nonopaque origins", () => {
message(frame, "host/terminal/read", "null", window);
message(frame, "host/terminal/read", "https://example.com");
expect(calls.host).not.toHaveBeenCalled();
});
it("uses the host-bound token instead of plugin-supplied identity", async () => {
message(frame, "host/terminal/read");
await vi.waitFor(() => expect(post).toHaveBeenCalled());
expect(calls.host).toHaveBeenCalledWith(
"fixed-token",
"host/terminal/read",
{ lines: 20, token: "forged-token" },
);
expect(post).toHaveBeenCalledWith(
{ type: "nyaterm-plugin-response", id: "1", result: { output: "hello" } },
"*",
);
});
it("does not expose arbitrary Tauri commands", () => {
message(frame, "get_otp_secret_value");
expect(calls.host).not.toHaveBeenCalled();
expect(calls.backend).not.toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(
expect.objectContaining({ error: "Unknown plugin capability" }),
"*",
);
});
it("rejects duplicate in-flight IDs and drops late responses after disposal", async () => {
let resolve!: (value: unknown) => void;
calls.host.mockImplementation(
() =>
new Promise((done) => {
resolve = done;
}),
);
message(frame, "host/terminal/read");
message(frame, "host/terminal/read");
expect(calls.host).toHaveBeenCalledTimes(1);
bridge.dispose();
resolve("late");
await Promise.resolve();
await Promise.resolve();
expect(post).not.toHaveBeenCalled();
});
it("rejects oversized payloads before invoking the backend", () => {
window.dispatchEvent(
new MessageEvent("message", {
source: frame.contentWindow,
origin: "null",
data: {
type: "nyaterm-plugin-request",
id: "large",
method: "host/storage/set",
params: "a".repeat(1024 * 1024 + 1),
},
}),
);
expect(calls.host).not.toHaveBeenCalled();
expect(post).toHaveBeenCalledWith(
expect.objectContaining({ error: "Plugin request is too large" }),
"*",
);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { getErrorMessage } from "@/lib/errors";
import { pluginApi } from "@/lib/plugins";
export type PluginBridgeContext = {
pluginId: string;
version: string;
theme: Record<string, string>;
};
/** Bind each bridge to exactly one frame and one backend-issued resource scope. */
export function createPluginBridge(
frame: HTMLIFrameElement,
token: string,
context: () => PluginBridgeContext,
) {
let disposed = false;
let pending = 0;
const inflight = new Set<string>();
const post = (message: unknown) => {
if (!disposed) frame.contentWindow?.postMessage(message, "*");
};
const receive = (event: MessageEvent) => {
if (
disposed ||
!frame.contentWindow ||
event.source !== frame.contentWindow ||
event.origin !== "null"
)
return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "nyaterm-plugin-ready") {
post({ type: "nyaterm-plugin-context", context: context() });
return;
}
if (
message.type !== "nyaterm-plugin-request" ||
typeof message.id !== "string" ||
message.id.length > 128 ||
!message.id ||
typeof message.method !== "string" ||
message.method.length > 128 ||
inflight.has(message.id)
)
return;
const id: string = message.id;
if (pending >= 32) {
post({
type: "nyaterm-plugin-response",
id,
error: "Too many pending plugin requests",
});
return;
}
let size: number;
try {
size = JSON.stringify(message.params ?? null).length;
} catch {
return;
}
if (size > 1024 * 1024) {
post({
type: "nyaterm-plugin-response",
id,
error: "Plugin request is too large",
});
return;
}
const call = message.method.startsWith("host/")
? pluginApi.hostCall
: message.method.startsWith("ui/") ||
message.method.startsWith("command/")
? pluginApi.backendCall
: null;
if (!call) {
post({
type: "nyaterm-plugin-response",
id,
error: "Unknown plugin capability",
});
return;
}
pending += 1;
inflight.add(id);
void call(token, message.method, message.params ?? null)
.then((result) => post({ type: "nyaterm-plugin-response", id, result }))
.catch((error) =>
post({
type: "nyaterm-plugin-response",
id,
error: getErrorMessage(error),
}),
)
.finally(() => {
pending -= 1;
inflight.delete(id);
});
};
window.addEventListener("message", receive);
return {
updateContext: () =>
post({ type: "nyaterm-plugin-context", context: context() }),
dispose: () => {
disposed = true;
window.removeEventListener("message", receive);
},
};
}
+100
View File
@@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import vm from "node:vm";
import { describe, expect, it, vi } from "vitest";
const transport = readFileSync(
"src-tauri/src/core/plugins/ipc_transport.js",
"utf8",
)
.replace("__TEMPLATE_invoke_key__", '"test-key"')
.replace("__TEMPLATE_os_name__", '"windows"')
.replace(
"__TEMPLATE_fetch_channel_data_command__",
'"plugin:__TAURI_CHANNEL__|fetch"',
)
.replace(
"__RAW_process_ipc_message_fn__",
readFileSync("src-tauri/src/core/plugins/process_ipc_message.js", "utf8"),
);
function harness(subframe = false, fallback = false) {
const postMessage = vi.fn();
const runCallback = vi.fn();
const fetch = fallback
? vi.fn().mockRejectedValue(new Error("blocked"))
: vi.fn().mockResolvedValue({
headers: new Headers({
"Tauri-Response": "ok",
"content-type": "application/json",
}),
json: async () => ({ done: true }),
});
const window = {
top: null as unknown,
ipc: { postMessage },
__TAURI_INTERNALS__: {
convertFileSrc: (cmd: string) => `http://ipc.localhost/${cmd}`,
runCallback,
},
};
window.top = subframe ? {} : window;
const context = vm.createContext({
window,
fetch,
Headers,
console: { warn: vi.fn() },
});
vm.runInContext(transport, context);
return { window, context, fetch, postMessage, runCallback };
}
describe("native IPC isolation", () => {
it("does not initialize a native transport in a subframe", () => {
const host = harness(true);
expect(host.window.__TAURI_INTERNALS__).not.toHaveProperty("postMessage");
expect(host.fetch).not.toHaveBeenCalled();
expect(host.postMessage).not.toHaveBeenCalled();
});
it("preserves top-level binary and channel serialization", async () => {
const host = harness();
vm.runInContext(
`window.__TAURI_INTERNALS__.postMessage({cmd:'write',callback:1,error:2,payload:new Uint8Array([0,255])})`,
host.context,
);
await vi.waitFor(() =>
expect(host.runCallback).toHaveBeenCalledWith(1, { done: true }),
);
const options = host.fetch.mock.calls[0][1];
expect(options.headers.get("Tauri-Invoke-Key")).toBe("test-key");
expect(options.headers.get("Content-Type")).toBe(
"application/octet-stream",
);
expect(Array.from(options.body)).toEqual([0, 255]);
vm.runInContext(
`window.__TAURI_INTERNALS__.postMessage({cmd:'objects',callback:3,error:4,payload:{bytes:new Uint8Array([1,2]),buffer:new Uint8Array([3]).buffer,map:new Map([['key','value']]),channel:{__TAURI_TO_IPC_KEY__:()=> '__CHANNEL__:7'}}})`,
host.context,
);
expect(JSON.parse(host.fetch.mock.calls[1][1].body)).toEqual({
bytes: [1, 2],
buffer: [3],
map: { key: "value" },
channel: "__CHANNEL__:7",
});
});
it("retains the keyed postMessage fallback only in the main frame", async () => {
const host = harness(false, true);
vm.runInContext(
`window.__TAURI_INTERNALS__.postMessage({cmd:'write',callback:1,error:2,payload:{value:42}})`,
host.context,
);
await vi.waitFor(() => expect(host.postMessage).toHaveBeenCalledOnce());
expect(JSON.parse(host.postMessage.mock.calls[0][0])).toMatchObject({
cmd: "write",
payload: { value: 42 },
__TAURI_INVOKE_KEY__: "test-key",
options: { customProtocolIpcBlocked: true },
});
});
});
+71
View File
@@ -0,0 +1,71 @@
import sdk from "../../plugins/sdk/nyaterm.js?raw";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPluginBridge } from "./pluginBridge";
import { pluginApi } from "./plugins";
vi.mock("./plugins", () => ({
pluginApi: { hostCall: vi.fn(), backendCall: vi.fn() },
}));
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
describe("plugin SDK round trip", () => {
it("handshakes, applies theme and routes a scoped request through the host bridge", async () => {
const frame = document.createElement("iframe");
document.body.append(frame);
const listeners = new Map<string, (event?: unknown) => void>();
const parent = {
postMessage: (data: unknown) =>
window.dispatchEvent(
new MessageEvent("message", {
data,
source: frame.contentWindow,
origin: "null",
}),
),
};
const pluginWindow = {
parent,
addEventListener: (name: string, listener: (event?: unknown) => void) =>
listeners.set(name, listener),
NyaTerm: undefined as unknown,
};
vi.spyOn(frame.contentWindow!, "postMessage").mockImplementation((data) =>
listeners.get("message")?.({ source: parent, data }),
);
const bridge = createPluginBridge(frame, "fixed-scope", () => ({
pluginId: "example.tools",
version: "1.0.0",
theme: { "--background": "black" },
}));
new Function("window", "document", sdk)(pluginWindow, document);
const api = pluginWindow.NyaTerm as {
ready: Promise<unknown>;
session: () => Promise<unknown>;
storage: { get: (key: string) => Promise<unknown> };
};
await expect(api.ready).resolves.toMatchObject({
pluginId: "example.tools",
});
expect(
document.documentElement.style.getPropertyValue("--background"),
).toBe("black");
vi.mocked(pluginApi.hostCall).mockResolvedValueOnce({ name: "Local" });
await expect(api.session()).resolves.toEqual({ name: "Local" });
expect(pluginApi.hostCall).toHaveBeenCalledWith(
"fixed-scope",
"host/session",
null,
);
vi.mocked(pluginApi.hostCall).mockRejectedValueOnce(
new Error("Permission denied"),
);
await expect(api.storage.get("setting")).rejects.toThrow(
"Permission denied",
);
bridge.dispose();
document.documentElement.style.removeProperty("--background");
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/invoke", () => ({ invoke: vi.fn() }));
import {
getPluginCommands,
getPluginPanels,
parsePluginPanelId,
pluginPanelId,
} from "./plugins";
import type { InstalledPlugin } from "@/types/plugins";
const plugin: InstalledPlugin = {
id: "example.tools",
activeVersion: "1.0.0",
enabled: true,
grantedPermissions: [],
versions: {
"1.0.0": {
digest: "digest",
manifest: {
manifestVersion: 1,
id: "example.tools",
name: "Tools",
version: "1.0.0",
description: "Tools",
publisher: "Example",
engine: ">=1",
permissions: [],
contributions: {
panels: [{ id: "view", title: "View", entry: "ui/index.html" }],
commands: [
{ id: "open", title: "Open", panel: "view", menus: ["terminal"] },
{
id: "connection",
title: "Inspect",
method: "command/inspect",
menus: ["connection"],
},
],
},
},
},
},
};
describe("plugin contributions", () => {
it("namespaces panel IDs and rejects ambiguous IDs", () => {
expect(parsePluginPanelId(pluginPanelId("example.tools", "view"))).toEqual({
pluginId: "example.tools",
panelId: "view",
});
for (const id of [
"fileExplorer",
"plugin:a:b:c",
"plugin:../a:view",
"plugin::view",
])
expect(parsePluginPanelId(id)).toBeNull();
});
it("only exposes enabled active-version contributions", () => {
expect(getPluginPanels([plugin])).toHaveLength(1);
expect(getPluginPanels([{ ...plugin, enabled: false }])).toEqual([]);
expect(getPluginPanels([{ ...plugin, activeVersion: "missing" }])).toEqual(
[],
);
expect(
getPluginCommands([plugin], "terminal").map((command) => command.id),
).toEqual(["open"]);
expect(
getPluginCommands([plugin], "connection").map((command) => command.id),
).toEqual(["connection"]);
});
});
+96
View File
@@ -0,0 +1,96 @@
import { invoke } from "@/lib/invoke";
import type {
InstalledPlugin,
PluginDiagnosticsSnapshot,
PluginPackagePreview,
PluginScope,
} from "@/types/plugins";
export const pluginApi = {
diagnostics: (pluginId: string) =>
invoke<PluginDiagnosticsSnapshot>("get_plugin_diagnostics", { pluginId }),
clearLogs: (pluginId: string) => invoke<void>("clear_plugin_logs", { pluginId }),
stopBackend: (pluginId: string) => invoke<void>("stop_plugin_backend", { pluginId }),
list: () => invoke<InstalledPlugin[]>("list_plugins"),
inspect: (path: string) => invoke<PluginPackagePreview>("inspect_plugin_package", { path }),
install: (path: string, digest: string) =>
invoke<InstalledPlugin>("install_plugin_package", { path, digest }),
configure: (
pluginId: string,
expectedVersion: string,
enabled: boolean,
grantedPermissions: string[],
) =>
invoke<void>("configure_plugin", {
pluginId,
expectedVersion,
enabled,
grantedPermissions,
}),
activate: (pluginId: string, version: string) =>
invoke<void>("activate_plugin_version", { pluginId, version }),
uninstall: (pluginId: string) => invoke<void>("uninstall_plugin", { pluginId }),
createScope: (pluginId: string, sessionId: string | null) =>
invoke<PluginScope>("create_plugin_scope", { pluginId, sessionId }),
closeScope: (token: string) => invoke<void>("close_plugin_scope", { token }),
hostCall: (token: string, method: string, params: unknown) =>
invoke<unknown>("plugin_host_call", { token, method, params }),
backendCall: (token: string, method: string, params: unknown) =>
invoke<unknown>("plugin_backend_call", { token, method, params }),
approve: (requestId: string, approved: boolean) =>
invoke<void>("respond_plugin_approval", { requestId, approved }),
};
export function activeManifest(plugin: InstalledPlugin) {
return plugin.versions[plugin.activeVersion]?.manifest ?? null;
}
export function pluginPanelId(pluginId: string, panelId: string) {
return `plugin:${pluginId}:${panelId}`;
}
export function parsePluginPanelId(id: string | null | undefined) {
const match = id?.match(/^plugin:([a-z0-9][a-z0-9._-]{0,127}):([a-z0-9][a-z0-9._-]{0,127})$/);
return match ? { pluginId: match[1], panelId: match[2] } : null;
}
export function getPluginPanels(plugins: InstalledPlugin[]) {
return plugins.flatMap((plugin) => {
const manifest = activeManifest(plugin);
return plugin.enabled && manifest
? manifest.contributions.panels.map((panel) => ({
...panel,
pluginId: plugin.id,
activityId: pluginPanelId(plugin.id, panel.id),
}))
: [];
});
}
export function getPluginCommands(plugins: InstalledPlugin[], menu?: "terminal" | "connection") {
return plugins.flatMap((plugin) => {
const manifest = activeManifest(plugin);
return plugin.enabled && manifest
? manifest.contributions.commands
.filter((command) => !menu || command.menus.includes(menu))
.map((command) => ({
...command,
pluginId: plugin.id,
pluginName: manifest.name,
}))
: [];
});
}
export const PLUGIN_OPEN_EVENT = "nyaterm:open-plugin";
export type PluginOpenIntent = {
pluginId: string;
panelId?: string;
commandId?: string;
sessionId?: string | null;
connectionId?: string;
};
export function openPlugin(intent: PluginOpenIntent) {
window.dispatchEvent(new CustomEvent<PluginOpenIntent>(PLUGIN_OPEN_EVENT, { detail: intent }));
}
+11 -2
View File
@@ -8,6 +8,9 @@ import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource-variable/noto-sans-sc";
import "./index.css";
import { PluginProvider } from "./context/PluginContext";
import { PluginApprovalHost } from "./components/plugins/PluginApprovalHost";
import { PluginCommandHost } from "./components/plugins/PluginCommandHost";
import {
applyThemeToDOM,
THEME_CACHE_KEY,
@@ -50,7 +53,9 @@ if (windowType) {
// Child window: lightweight provider stack, no full App
// These entry points are independent and should load in parallel; serial awaits would add an
// unnecessary chunk round trip to every child-window open.
const childRoot = ReactDOM.createRoot(document.getElementById("root") as HTMLElement);
const childRoot = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);
// Commit an inline-background loading shell before loading provider and page chunks. This lets
// the parent reveal a stable surface without reintroducing the macOS white or empty window.
childRoot.render(
@@ -141,7 +146,11 @@ if (windowType) {
<ErrorBoundary>
<AppProvider>
<ThemeProvider>
<App />
<PluginProvider>
<App />
<PluginApprovalHost />
<PluginCommandHost />
</PluginProvider>
<Toaster />
</ThemeProvider>
</AppProvider>
+77
View File
@@ -0,0 +1,77 @@
export type PluginPermission = string;
export interface PluginDiagnosticsSnapshot {
status: "idle" | "starting" | "running" | "stopped" | "error";
version: string;
lastError: string | null;
logs: {
timestamp: number;
version: string;
level: string;
source: string;
message: string;
}[];
}
export interface PluginPanelContribution {
id: string;
title: string;
entry: string;
}
export interface PluginCommandContribution {
id: string;
title: string;
panel?: string | null;
method?: string | null;
menus: ("terminal" | "connection")[];
}
export interface PluginManifest {
manifestVersion: number;
id: string;
name: string;
version: string;
description: string;
publisher: string;
engine: string;
permissions: PluginPermission[];
contributions: {
panels: PluginPanelContribution[];
commands: PluginCommandContribution[];
};
backend?: {
transport: "stdio-jsonl" | "stdio-framed";
executables: Record<string, string>;
} | null;
}
export interface InstalledPlugin {
id: string;
activeVersion: string;
enabled: boolean;
grantedPermissions: PluginPermission[];
versions: Record<string, { manifest: PluginManifest; digest: string }>;
}
export interface PluginPackagePreview {
manifest: PluginManifest;
digest: string;
expandedBytes: number;
}
export interface PluginScope {
token: string;
pluginId: string;
version: string;
}
export interface PluginApprovalRequest {
requestId: string;
pluginId: string;
pluginName: string;
capability: string;
sessionName: string;
summary: string;
risk: string;
}
+15 -7
View File
@@ -12,37 +12,45 @@ const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [react(), tailwindcss()],
optimizeDeps: {
entries: ["index.html"],
},
css: {
transformer: "lightningcss",
lightningcss: {
targets: browserslistToTargets(browserslist("safari >= 14, chrome >= 105")),
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
exclude: [...configDefaults.exclude, "**/src-tauri/vendor/**"],
exclude: [...configDefaults.exclude, "**/src-tauri/vendor/**", "**/temp/**"],
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
ignored: ["**/src-tauri/**", "**/temp/**"],
},
},
@@ -65,4 +73,4 @@ export default defineConfig(async () => ({
},
},
},
}));
}));