feat(plugin): add new plugin creation, packaging, and example commands; enhance Vite config and documentation for plugin development
This commit is contained in:
1 parent
7201039472
commit
bb0c24d5e5
84 files changed
+9683
-19
No files matched your search
@@ -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.
|
||||
@@ -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。
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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/",
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}),
|
||||
);
|
||||
Vendored
+48
@@ -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;
|
||||
@@ -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
@@ -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])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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`,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}));
|
||||
@@ -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);
|
||||
Generated
+21
@@ -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"
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶ms)?.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(¶ms, "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(¶ms, "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(¶ms, "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(¶ms, "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
|
||||
})
|
||||
})()
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(¶ms)?.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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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 () => ({
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}));
|
||||
Reference in new issue
Block a user