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,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>
|
||||
Reference in new issue
Block a user