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

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

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
# NyaTerm plugin development
Read the [plugin guide](../docs/plugins.md) for installation, manifests,
permissions, the UI SDK and native protocol.
- `sdk/nyaterm.js` and `sdk/nyaterm.d.ts`: browser SDK and typings.
- `examples/session-toolbox`: a sandboxed panel using scoped host capabilities.
- `examples/native-counter`: a persistent Rust command backend.
```sh
pnpm plugin:pack plugins/examples/session-toolbox temp/plugins/session-toolbox.nyap
pnpm plugin:example:native
pnpm plugin:pack temp/plugins/native-counter temp/plugins/native-counter.nyap
```
Install the generated packages through the Plugins activity panel and explicitly grant
their requested permissions. Native executables run with OS user permissions.
+247
View File
@@ -0,0 +1,247 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nyaterm-native-counter"
version = "1.0.1"
dependencies = [
"nyaterm-plugin-sdk",
"tokio",
]
[[package]]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,8 @@
[package]
name = "nyaterm-native-counter"
version = "1.0.1"
edition = "2024"
[dependencies]
nyaterm-plugin-sdk = { path = "../../sdk/rust/nyaterm-plugin-sdk" }
tokio = { version = "1", features = ["rt", "macros"] }
@@ -0,0 +1,19 @@
//! A persistent native command using the NyaTerm SDK.
use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json};
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
struct Counter { count: AtomicU64 }
#[async_trait]
impl Plugin for Counter {
async fn call(&self, context: Context, method: &str, _input: Value) -> Result<Value> {
if method != "command/count" { return Err(RpcError::method_not_found(method)); }
let count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
context.log("info", &format!("Counter incremented to {count}")).await?;
Ok(json!({"count":count,"message":"This process is reused until the plugin stops."}))
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { Server::from_env()?.serve(Counter::default()).await }
@@ -0,0 +1,29 @@
{
"contributions": {
"panels": [
{ "id": "toolbox", "title": "Session Toolbox", "entry": "ui/index.html" }
],
"commands": [
{
"id": "open-toolbox",
"title": "Open Session Toolbox",
"panel": "toolbox",
"menus": ["terminal", "connection"]
}
]
},
"description": "Inspect the selected terminal, read recent output and run a command with explicit approval.",
"engine": ">=1.2.12, <2.0.0",
"id": "nyaterm.session-toolbox",
"manifestVersion": 1,
"name": "Session Toolbox",
"permissions": [
"session.read",
"terminal.read",
"terminal.execute",
"filesystem.read",
"storage"
],
"publisher": "NyaTerm example",
"version": "1.0.0"
}
@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Session Toolbox</title>
<style>
:root {
color-scheme: dark light;
}
body {
margin: 0;
padding: 16px;
font:
13px/1.5 system-ui,
sans-serif;
background: var(--df-bg, #111827);
color: var(--df-fg, #e5e7eb);
}
h1 {
font-size: 17px;
margin: 0 0 12px;
}
label {
display: block;
margin: 16px 0 6px;
}
input,
button {
font: inherit;
border: 1px solid #64748b;
border-radius: 5px;
padding: 7px 9px;
}
input {
box-sizing: border-box;
width: 100%;
background: transparent;
color: inherit;
}
button {
margin-top: 8px;
cursor: pointer;
background: #2563eb;
color: white;
}
button:disabled {
opacity: 0.5;
cursor: wait;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
padding: 10px;
background: #64748b22;
border-radius: 5px;
max-height: 400px;
overflow: auto;
}
#error {
color: #f87171;
}
.hint {
opacity: 0.7;
font-size: 12px;
}
</style>
<script src="nyaterm-sdk.js"></script>
<script src="toolbox.js" defer></script>
</head>
<body>
<h1>Session Toolbox</h1>
<p id="session">Waiting for NyaTerm…</p>
<p class="hint">Operations apply only to this view’s selected session.</p>
<button id="read">Read recent output</button>
<label for="command">Command</label>
<input id="command" placeholder="Enter a command" />
<button id="execute">Request execution</button>
<p class="hint">NyaTerm asks for approval before executing.</p>
<label for="path">Text file path</label>
<input id="path" placeholder="Path in the selected session" />
<button id="file">Request file read</button>
<p id="error" role="alert"></p>
<pre id="output" aria-live="polite"></pre>
</body>
</html>
@@ -0,0 +1,46 @@
"use strict";
const output = document.getElementById("output");
const error = document.getElementById("error");
const buttons = [...document.querySelectorAll("button")];
async function run(operation) {
error.textContent = "";
for (const button of buttons) button.disabled = true;
try {
const result = await operation();
output.textContent =
typeof result === "string" ? result : JSON.stringify(result, null, 2);
} catch (failure) {
error.textContent = failure.message;
} finally {
for (const button of buttons) button.disabled = false;
}
}
document.getElementById("read").addEventListener("click", () =>
run(async () => {
await NyaTerm.storage.set("read-lines", 100);
return NyaTerm.terminal.read(100);
}),
);
document.getElementById("execute").addEventListener("click", () =>
run(async () => {
const command = document.getElementById("command").value;
return NyaTerm.terminal.execute(command);
}),
);
document
.getElementById("file")
.addEventListener("click", () =>
run(() => NyaTerm.filesystem.read(document.getElementById("path").value)),
);
void NyaTerm.ready.then(() =>
run(async () => {
const session = await NyaTerm.session();
document.getElementById("session").textContent = session
? `${session.name} (${session.type})`
: "Select a terminal session and reopen this panel.";
const lines = (await NyaTerm.storage.get("read-lines")) || 100;
document.getElementById("read").textContent = `Read ${lines} lines`;
return "Ready";
}),
);
+48
View File
@@ -0,0 +1,48 @@
/** Include nyaterm-sdk.js as a classic script. Await NyaTerm.ready before host calls. */
type NyaTermJson =
| null
| boolean
| number
| string
| NyaTermJson[]
| { [key: string]: NyaTermJson };
interface NyaTermPluginContext {
pluginId: string;
version: string;
theme: Record<string, string>;
}
interface NyaTermPluginSdk {
readonly ready: Promise<NyaTermPluginContext>;
readonly context: NyaTermPluginContext | null;
onContextChange(
listener: (context: NyaTermPluginContext) => void,
): () => void;
call<T = NyaTermJson>(method: string, params?: NyaTermJson): Promise<T>;
session(): Promise<{
id: string;
name: string;
type: string;
connected: boolean;
} | null>;
terminal: {
read(lines?: number): Promise<{ output: string }>;
execute(command: string, timeoutMs?: number): Promise<NyaTermJson>;
};
filesystem: { read(path: string): Promise<{ content: string }> };
storage: {
get(key: string): Promise<NyaTermJson>;
set(key: string, value: NyaTermJson): Promise<null>;
};
network: {
request(
url: string,
options?: {
method?: "GET" | "POST";
body?: string;
contentType?: string;
},
): Promise<{ status: number; contentType: string; body: string }>;
};
backend<T = NyaTermJson>(method: string, input?: NyaTermJson): Promise<T>;
}
declare const NyaTerm: NyaTermPluginSdk;
+85
View File
@@ -0,0 +1,85 @@
/* NyaTerm Plugin UI SDK v1. Include this as a classic script before plugin code. */
(() => {
"use strict";
const pending = new Map();
const contextListeners = new Set();
let context = null;
let nextId = 0;
let resolveReady;
const ready = new Promise((resolve) => {
resolveReady = resolve;
});
function call(method, params = null) {
if (pending.size >= 32)
return Promise.reject(new Error("Too many pending requests"));
const id = String(++nextId);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("Plugin host request timed out"));
}, 185000);
pending.set(id, { resolve, reject, timer });
window.parent.postMessage(
{ type: "nyaterm-plugin-request", id, method, params },
"*",
);
});
}
window.addEventListener("message", (event) => {
if (event.source !== window.parent) return;
const message = event.data;
if (!message || typeof message !== "object") return;
if (message.type === "nyaterm-plugin-context") {
context = message.context;
for (const [key, value] of Object.entries(context.theme || {})) {
document.documentElement.style.setProperty(key, value);
}
resolveReady(context);
for (const listener of contextListeners) listener(context);
} else if (message.type === "nyaterm-plugin-response") {
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timer);
if (message.error) request.reject(new Error(message.error));
else request.resolve(message.result);
}
});
window.addEventListener("pagehide", () => {
for (const request of pending.values()) {
clearTimeout(request.timer);
request.reject(new Error("Plugin view closed"));
}
pending.clear();
});
window.NyaTerm = Object.freeze({
ready,
call,
get context() {
return context;
},
onContextChange(listener) {
contextListeners.add(listener);
return () => contextListeners.delete(listener);
},
session: () => call("host/session"),
terminal: Object.freeze({
read: (lines = 100) => call("host/terminal/read", { lines }),
execute: (command, timeoutMs = 30000) =>
call("host/terminal/execute", { command, timeoutMs }),
}),
filesystem: Object.freeze({
read: (path) => call("host/filesystem/read", { path }),
}),
storage: Object.freeze({
get: (key) => call("host/storage/get", { key }),
set: (key, value) => call("host/storage/set", { key, value }),
}),
network: Object.freeze({
request: (url, options = {}) =>
call("host/network/request", { ...options, url }),
}),
backend: (method, input = null) => call(method, input),
});
window.parent.postMessage({ type: "nyaterm-plugin-ready" }, "*");
})();
+239
View File
@@ -0,0 +1,239 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
dependencies = [
"async-trait",
"futures-util",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"pin-project-lite",
"tokio-macros",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"futures-util",
"pin-project-lite",
"tokio",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
@@ -0,0 +1,19 @@
[package]
name = "nyaterm-plugin-sdk"
version = "0.1.0"
edition = "2024"
rust-version = "1.94"
description = "Native plugin SDK for NyaTerm's scoped JSON-RPC sidecar protocol"
license = "MIT OR Apache-2.0"
[dependencies]
async-trait = "0.1"
futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["io-util", "io-std", "sync", "time", "rt", "macros"] }
tokio-util = { version = "0.7", features = ["rt"] }
[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread"] }
@@ -0,0 +1,59 @@
# NyaTerm native plugin SDK
This standalone crate implements the native v1 protocol without depending on
Tauri. Generate a working project with:
```sh
pnpm plugin:create example.hello temp/plugins/hello --template rust
node temp/plugins/hello/build.mjs
pnpm plugin:pack temp/plugins/hello temp/plugins/hello.nyap
```
Implement `Plugin::call(Context, method, input)` and launch
`Server::from_env()?.serve(plugin).await`. The host supplies plugin identity,
version and transport in the environment; the SDK validates the initialization
handshake against those values and API/protocol v1. `Plugin::initialize` can
perform additional application checks. JSONL and framed transports use the same
API. Only framed transport supports `Context::send_binary` and `Plugin::binary`.
`Context::host()` exposes `session`, `terminal_read`, `terminal_execute`,
`file_read`, `storage_get`, `storage_set`, and generic `call`/`call_with_timeout`.
The generic API accepts host methods such as `host/network/request` with the same
input documented in the UI SDK. Each client carries its originating request's
scope; it cannot construct another plugin/session scope. Manifest grants and host
approval remain necessary.
Use `RpcError::invalid`, `method_not_found`, or `new` for errors. Returning an
error ends that invocation; it does not stop the backend. Handler panics with
Rust's unwind mode return a generic RPC error; abort panics and process exits
terminate the backend. Do not log request inputs, terminal output or secrets.
`Context::log("info", "Task finished").await` emits a structured diagnostic.
stdout is reserved for protocol traffic; stderr is also collected by the host.
The SDK permits 64 active handlers and 16 outstanding reverse calls. Frames are
bounded to 2 MiB JSON / 8 MiB binary, with 128-byte binary channels. Initialization
has a 10-second deadline, handlers and default host calls 180 seconds; the host
may cancel sooner (NyaTerm backend invocations currently allow 170 seconds).
For shorter reverse deadlines use `call_with_timeout`.
Cancellation drops the async handler and cancels its host client. Dropped/timed-out
reverse calls notify the host to cancel outstanding work. Cloned contexts expire
when their original request finishes. Long work should yield and check
`context.is_cancelled()` or select on `context.cancelled()`. Avoid blocking the
Tokio executor. Cancellation does not undo an already executed terminal command
or automatically stop plugin-owned threads/subprocesses.
Native execution is an OS process with the user's privileges. This SDK supplies
the protocol and scoped host client; it does not create an OS sandbox. The crate
is currently consumed as a local path dependency and is not published on crates.io.
Verification:
```sh
cargo test --manifest-path plugins/sdk/rust/nyaterm-plugin-sdk/Cargo.toml
cargo test --manifest-path src-tauri/crates/nyaterm-plugin-runtime/Cargo.toml --features sdk-fixture
```
The latter launches an SDK executable through the real host in both transports,
including concurrent/reverse calls, logs, binary frames, cancellation, errors,
panic recovery, crashes and startup failure diagnostics.
@@ -0,0 +1,480 @@
//! Scoped native plugin server. stdout is reserved for protocol traffic.
mod wire;
pub use async_trait::async_trait;
use futures_util::FutureExt;
use serde::{Deserialize, Serialize};
pub use serde_json::{Value, json};
use std::collections::HashMap;
use std::sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
};
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncWrite, BufReader};
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use wire::Frame;
pub type Result<T> = std::result::Result<T, RpcError>;
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("{message}")]
pub struct RpcError {
pub code: i32,
pub message: String,
}
impl RpcError {
pub fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn invalid(message: impl Into<String>) -> Self {
Self::new(-32602, message)
}
pub fn method_not_found(method: &str) -> Self {
Self::new(-32601, format!("Unknown method: {method}"))
}
pub fn cancelled() -> Self {
Self::new(-32800, "Request cancelled")
}
}
impl From<std::io::Error> for RpcError {
fn from(error: std::io::Error) -> Self {
Self::new(-32000, error.to_string())
}
}
impl From<serde_json::Error> for RpcError {
fn from(error: serde_json::Error) -> Self {
Self::invalid(error.to_string())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Jsonl,
Framed,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Initialization {
pub plugin_id: String,
pub plugin_version: String,
pub app_version: String,
pub api_version: u32,
pub protocol_version: u32,
pub permissions: Vec<String>,
}
#[async_trait]
pub trait Plugin: Send + Sync + 'static {
async fn initialize(&self, _info: Initialization) -> Result<()> {
Ok(())
}
async fn call(&self, context: Context, method: &str, input: Value) -> Result<Value>;
async fn binary(&self, _channel: String, _data: Vec<u8>) -> Result<()> {
Ok(())
}
}
struct Shared {
writer: mpsc::Sender<Frame>,
pending: Mutex<HashMap<String, oneshot::Sender<Result<Value>>>>,
active: Mutex<HashMap<u64, CancellationToken>>,
next_id: AtomicU64,
stop: CancellationToken,
transport: Transport,
}
/// A host client is tied to the scope of one host-owned request.
#[derive(Clone)]
pub struct HostClient {
shared: Arc<Shared>,
scope: String,
cancellation: CancellationToken,
}
struct Pending {
shared: Arc<Shared>,
id: String,
}
struct Active {
shared: Arc<Shared>,
id: u64,
}
impl Drop for Active {
fn drop(&mut self) {
if let Some(token) = self.shared.active.lock().unwrap().remove(&self.id) {
token.cancel();
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if self
.shared
.pending
.lock()
.unwrap()
.remove(&self.id)
.is_some()
{
let frame = Frame::Json(
json!({"jsonrpc":"2.0","method":"$/cancelRequest","params":{"id":self.id}}),
);
if let Err(mpsc::error::TrySendError::Full(frame)) = self.shared.writer.try_send(frame)
{
let writer = self.shared.writer.clone();
tokio::spawn(async move {
let _ = tokio::time::timeout(Duration::from_secs(1), writer.send(frame)).await;
});
}
}
}
}
impl HostClient {
pub async fn call(&self, method: &str, input: Value) -> Result<Value> {
self.call_with_timeout(method, input, Duration::from_secs(180))
.await
}
pub async fn call_with_timeout(
&self,
method: &str,
input: Value,
timeout: Duration,
) -> Result<Value> {
if self.cancellation.is_cancelled() || self.shared.stop.is_cancelled() {
return Err(RpcError::cancelled());
}
if !method.starts_with("host/")
|| method.len() > 128
|| serde_json::to_vec(&input)?.len() > 1024 * 1024
{
return Err(RpcError::invalid("Invalid host request"));
}
let id = format!(
"sdk-{}",
self.shared.next_id.fetch_add(1, Ordering::Relaxed)
);
let (sender, receiver) = oneshot::channel();
{
let mut pending = self.shared.pending.lock().unwrap();
if pending.len() >= 16 {
return Err(RpcError::new(-32000, "Too many pending host calls"));
}
pending.insert(id.clone(), sender);
}
let _pending = Pending {
shared: self.shared.clone(),
id: id.clone(),
};
let operation = async {
self.shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"method":method,"params":{"scopeToken":self.scope,"input":input}}))).await.map_err(|_|RpcError::new(-32000,"Protocol writer closed"))?;
receiver
.await
.map_err(|_| RpcError::new(-32000, "Host response channel closed"))?
};
tokio::select! {
_=self.shared.stop.cancelled()=>Err(RpcError::cancelled()),
_=self.cancellation.cancelled()=>Err(RpcError::cancelled()),
result=tokio::time::timeout(timeout,operation)=>result.unwrap_or_else(|_|Err(RpcError::new(-32000,"Host call timed out"))),
}
}
pub async fn session(&self) -> Result<Value> {
self.call("host/session", Value::Null).await
}
pub async fn terminal_read(&self, lines: u16) -> Result<Value> {
self.call("host/terminal/read", json!({"lines":lines}))
.await
}
pub async fn terminal_execute(&self, command: &str, timeout_ms: u64) -> Result<Value> {
self.call(
"host/terminal/execute",
json!({"command":command,"timeoutMs":timeout_ms}),
)
.await
}
pub async fn file_read(&self, path: &str) -> Result<Value> {
self.call("host/filesystem/read", json!({"path":path}))
.await
}
pub async fn storage_get(&self, key: &str) -> Result<Value> {
self.call("host/storage/get", json!({"key":key})).await
}
pub async fn storage_set(&self, key: &str, value: Value) -> Result<Value> {
self.call("host/storage/set", json!({"key":key,"value":value}))
.await
}
}
#[derive(Clone)]
pub struct Context {
host: HostClient,
}
impl Context {
pub fn host(&self) -> &HostClient {
&self.host
}
pub fn is_cancelled(&self) -> bool {
self.host.cancellation.is_cancelled() || self.host.shared.stop.is_cancelled()
}
pub async fn cancelled(&self) {
tokio::select! {_=self.host.cancellation.cancelled()=>{},_=self.host.shared.stop.cancelled()=>{}}
}
pub async fn log(&self, level: &str, message: &str) -> Result<()> {
if !["debug", "info", "warn", "error"].contains(&level) || message.len() > 2048 {
return Err(RpcError::invalid("Invalid log entry"));
}
self.send(Frame::Json(json!({"jsonrpc":"2.0","method":"event/log","params":{"level":level,"message":message}}))).await
}
pub async fn send_binary(&self, channel: &str, data: Vec<u8>) -> Result<()> {
if self.host.shared.transport != Transport::Framed
|| channel.is_empty()
|| channel.len() > 128
|| channel.len() + data.len() + 2 > wire::MAX_FRAME
{
return Err(RpcError::invalid("Invalid binary frame"));
}
self.send(Frame::Binary(channel.to_owned(), data)).await
}
async fn send(&self, frame: Frame) -> Result<()> {
tokio::select! {
_=self.cancelled()=>Err(RpcError::cancelled()),
result=self.host.shared.writer.send(frame)=>result.map_err(|_|RpcError::new(-32000,"Protocol writer closed")),
}
}
}
pub struct Server {
id: String,
version: String,
transport: Transport,
}
impl Server {
pub fn new(id: impl Into<String>, version: impl Into<String>, transport: Transport) -> Self {
Self {
id: id.into(),
version: version.into(),
transport,
}
}
pub fn from_env() -> Result<Self> {
let get = |key| {
std::env::var(key).map_err(|_| {
RpcError::invalid(format!(
"Missing {key}; launch this executable from NyaTerm"
))
})
};
let transport = match std::env::var("NYATERM_PLUGIN_TRANSPORT").as_deref() {
Ok("stdio-framed") => Transport::Framed,
Ok("stdio-jsonl") | Err(_) => Transport::Jsonl,
_ => return Err(RpcError::invalid("Unsupported transport")),
};
Ok(Self::new(
get("NYATERM_PLUGIN_ID")?,
get("NYATERM_PLUGIN_VERSION")?,
transport,
))
}
pub async fn serve(self, plugin: impl Plugin) -> Result<()> {
self.serve_io(plugin, tokio::io::stdin(), tokio::io::stdout())
.await
}
pub async fn serve_io(
self,
plugin: impl Plugin,
reader: impl AsyncRead + Unpin + Send + 'static,
writer: impl AsyncWrite + Unpin + Send + 'static,
) -> Result<()> {
let (sender, mut receiver) = mpsc::channel(8);
let shared = Arc::new(Shared {
writer: sender,
pending: Mutex::new(HashMap::new()),
active: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
stop: CancellationToken::new(),
transport: self.transport,
});
let transport = self.transport;
let mut writer_task = tokio::spawn(async move {
let mut writer = writer;
while let Some(frame) = receiver.recv().await {
wire::write(&mut writer, transport, frame).await?;
}
Ok::<(), RpcError>(())
});
let plugin = Arc::new(plugin);
let mut reader = BufReader::new(reader);
let mut tasks = JoinSet::new();
let result = tokio::select! {
result = self.run(plugin, &mut reader, shared.clone(), &mut tasks) => result,
result = &mut writer_task => result.unwrap_or_else(|_| Err(RpcError::new(-32000, "Protocol writer panicked"))),
};
shared.stop.cancel();
tasks.abort_all();
while tasks.join_next().await.is_some() {}
for (_, pending) in shared.pending.lock().unwrap().drain() {
let _ = pending.send(Err(RpcError::cancelled()));
}
if !writer_task.is_finished() {
writer_task.abort();
let _ = writer_task.await;
}
result
}
async fn run(
&self,
plugin: Arc<impl Plugin>,
reader: &mut (impl AsyncRead + Unpin),
shared: Arc<Shared>,
tasks: &mut JoinSet<Result<()>>,
) -> Result<()> {
let mut initialized = false;
loop {
while let Some(result) = tasks.try_join_next() {
result.map_err(|_| RpcError::new(-32000, "Plugin handler panicked"))??;
}
let Some(frame) = wire::read(reader, self.transport).await? else {
return Ok(());
};
let value = match frame {
Frame::Binary(channel, data) => {
if !initialized {
return Err(RpcError::invalid("Initialize before sending binary frames"));
}
if tasks.len() >= 64 {
return Err(RpcError::invalid("Too many binary handlers"));
}
let plugin = plugin.clone();
tasks.spawn(async move {
tokio::time::timeout(Duration::from_secs(180), plugin.binary(channel, data))
.await
.map_err(|_| RpcError::new(-32000, "Binary handler timed out"))?
});
continue;
}
Frame::Json(value) => value,
};
if value.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Err(RpcError::invalid("Invalid JSON-RPC version"));
}
let Some(method) = value.get("method").and_then(Value::as_str) else {
let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
RpcError::invalid("Reverse host response IDs must be strings")
})?;
if let Some(sender) = shared.pending.lock().unwrap().remove(id) {
let result = if let Some(error) = value.get("error") {
Err(serde_json::from_value(error.clone())?)
} else {
value
.get("result")
.cloned()
.ok_or_else(|| RpcError::invalid("Host response has no result"))
};
let _ = sender.send(result);
}
continue;
};
if method == "$/cancelRequest" {
if let Some(id) = value.pointer("/params/id").and_then(Value::as_u64)
&& let Some(token) = shared.active.lock().unwrap().get(&id)
{
token.cancel();
}
continue;
}
let id = value
.get("id")
.and_then(Value::as_u64)
.ok_or_else(|| RpcError::invalid("Host request IDs must be numeric"))?;
if method == "plugin/initialize" {
if initialized {
return Err(RpcError::invalid("Plugin is already initialized"));
}
let info: Initialization =
serde_json::from_value(value.get("params").cloned().unwrap_or(Value::Null))?;
if info.plugin_id != self.id
|| info.plugin_version != self.version
|| info.api_version != 1
|| info.protocol_version != 1
{
return Err(RpcError::invalid(
"Incompatible initialization identity or API version",
));
}
tokio::time::timeout(Duration::from_secs(10), plugin.initialize(info))
.await
.map_err(|_| RpcError::new(-32000, "Initialization timed out"))??;
initialized = true;
shared.writer.send(Frame::Json(json!({"jsonrpc":"2.0","id":id,"result":{"pluginId":self.id,"pluginVersion":self.version,"protocolVersion":1}})))
.await.map_err(|_| RpcError::new(-32000, "Protocol writer closed"))?;
continue;
}
if !initialized || method.is_empty() || method.len() > 128 {
return Err(RpcError::invalid(
"Plugin is not initialized or method is invalid",
));
}
let scope = value
.pointer("/params/scopeToken")
.and_then(Value::as_str)
.filter(|s| !s.is_empty() && s.len() <= 256)
.ok_or_else(|| RpcError::invalid("Request needs its scope token"))?
.to_owned();
let input = value
.pointer("/params/input")
.cloned()
.unwrap_or(Value::Null);
let cancellation = shared.stop.child_token();
{
let mut active = shared.active.lock().unwrap();
if active.len() >= 64 || active.contains_key(&id) {
return Err(RpcError::invalid(
"Too many requests or duplicate request ID",
));
}
active.insert(id, cancellation.clone());
}
let context = Context {
host: HostClient {
shared: shared.clone(),
scope,
cancellation: cancellation.clone(),
},
};
let plugin = plugin.clone();
let shared = shared.clone();
let method = method.to_owned();
tasks.spawn(async move {
let _active = Active {
shared: shared.clone(),
id,
};
let call = std::panic::AssertUnwindSafe(plugin.call(context, &method, input))
.catch_unwind();
let result = tokio::select! {
_ = cancellation.cancelled() => Err(RpcError::cancelled()),
result = tokio::time::timeout(Duration::from_secs(180), call) => match result {
Ok(Ok(result)) => result,
Ok(Err(_)) => Err(RpcError::new(-32000, "Plugin handler panicked")),
Err(_) => Err(RpcError::new(-32000, "Plugin handler timed out")),
},
};
let response = match result {
Ok(result) => json!({"jsonrpc":"2.0","id":id,"result":result}),
Err(error) => json!({"jsonrpc":"2.0","id":id,"error":error}),
};
shared
.writer
.send(Frame::Json(response))
.await
.map_err(|_| RpcError::new(-32000, "Protocol writer closed"))
});
}
}
}
@@ -0,0 +1,143 @@
use crate::{Result, RpcError, Transport};
use serde_json::Value;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub(crate) const MAX_JSON: usize = 2 * 1024 * 1024;
pub(crate) const MAX_FRAME: usize = 8 * 1024 * 1024;
pub(crate) enum Frame {
Json(Value),
Binary(String, Vec<u8>),
}
pub(crate) async fn read(
reader: &mut (impl AsyncRead + Unpin),
transport: Transport,
) -> Result<Option<Frame>> {
let mut first = [0_u8];
if reader.read(&mut first).await? == 0 {
return Ok(None);
}
if transport == Transport::Jsonl {
let mut bytes = Vec::new();
let mut byte = first[0];
loop {
if byte == b'\n' {
break;
}
if bytes.len() == MAX_JSON {
return Err(RpcError::invalid("JSON line exceeds 2 MiB"));
}
bytes.push(byte);
byte = reader.read_u8().await?;
}
return Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?)));
}
let kind = first[0];
let length = reader.read_u32_le().await? as usize;
if length > MAX_FRAME || (kind == 1 && length > MAX_JSON) {
return Err(RpcError::invalid("Frame is too large"));
}
let mut bytes = vec![0; length];
reader.read_exact(&mut bytes).await?;
match kind {
1 => Ok(Some(Frame::Json(serde_json::from_slice(&bytes)?))),
2 if bytes.len() >= 2 => {
let length = u16::from_le_bytes([bytes[0], bytes[1]]) as usize;
if length == 0 || length > 128 || length + 2 > bytes.len() {
return Err(RpcError::invalid("Invalid binary channel"));
}
let channel = std::str::from_utf8(&bytes[2..length + 2])
.map_err(|_| RpcError::invalid("Invalid binary channel encoding"))?
.to_owned();
Ok(Some(Frame::Binary(channel, bytes[length + 2..].to_vec())))
}
_ => Err(RpcError::invalid("Invalid frame kind")),
}
}
pub(crate) async fn write(
writer: &mut (impl AsyncWrite + Unpin),
transport: Transport,
frame: Frame,
) -> Result<()> {
let (kind, bytes) = match frame {
Frame::Json(value) => {
let bytes = serde_json::to_vec(&value)?;
if bytes.len() > MAX_JSON {
return Err(RpcError::invalid("JSON frame exceeds 2 MiB"));
}
(1, bytes)
}
Frame::Binary(channel, data) => {
if transport != Transport::Framed
|| channel.is_empty()
|| channel.len() > 128
|| channel.len() + data.len() + 2 > MAX_FRAME
{
return Err(RpcError::invalid("Invalid binary frame"));
}
let mut bytes = (channel.len() as u16).to_le_bytes().to_vec();
bytes.extend_from_slice(channel.as_bytes());
bytes.extend_from_slice(&data);
(2, bytes)
}
};
if transport == Transport::Framed {
writer.write_u8(kind).await?;
writer.write_u32_le(bytes.len() as u32).await?;
}
writer.write_all(&bytes).await?;
if transport == Transport::Jsonl {
writer.write_u8(b'\n').await?;
}
writer.flush().await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn distinguishes_clean_eof_from_truncated_frames() {
assert!(
read(
&mut std::io::Cursor::new(Vec::<u8>::new()),
Transport::Jsonl
)
.await
.unwrap()
.is_none()
);
for (transport, bytes) in [
(Transport::Jsonl, b"{\"x\":1}".to_vec()),
(Transport::Framed, vec![1, 2, 0]),
] {
assert!(
read(&mut std::io::Cursor::new(bytes), transport)
.await
.is_err()
);
}
}
#[tokio::test]
async fn rejects_frame_size_before_reading_payload_and_roundtrips_binary() {
let mut bytes = vec![1];
bytes.extend_from_slice(&(MAX_JSON as u32 + 1).to_le_bytes());
let error = read(&mut std::io::Cursor::new(bytes), Transport::Framed)
.await
.err()
.unwrap();
assert!(error.message.contains("too large"));
let mut bytes = Vec::new();
write(
&mut bytes,
Transport::Framed,
Frame::Binary("test".into(), vec![0, 255]),
)
.await
.unwrap();
assert!(
matches!(read(&mut std::io::Cursor::new(bytes),Transport::Framed).await.unwrap(),Some(Frame::Binary(channel,data)) if channel=="test" && data==[0,255])
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = fileURLToPath(new URL("./", import.meta.url));
const os = { win32: "windows", darwin: "macos", linux: "linux" }[
process.platform
];
const arch = { x64: "x86_64", arm64: "aarch64" }[process.arch];
if (!os || !arch)
throw new Error(
"Unsupported platform; build and add your executable manually",
);
const result = spawnSync(
"cargo",
[
"build",
"--release",
"--manifest-path",
path.join(root, "Cargo.toml"),
"--target-dir",
path.join(root, "target"),
],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
const executable = `nyaterm-plugin-starter${process.platform === "win32" ? ".exe" : ""}`;
const directory = path.join(root, "bin", `${os}-${arch}`);
mkdirSync(directory, { recursive: true });
copyFileSync(
path.join(root, "target/release", executable),
path.join(directory, executable),
);
const manifestPath = path.join(root, "manifest.json");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
manifest.backend.executables[`${os}-${arch}`] =
`bin/${os}-${arch}/${executable}`;
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
process.stdout.write("Built native backend; the plugin is ready to package.\n");
+22
View File
@@ -0,0 +1,22 @@
use nyaterm_plugin_sdk::{Context, Plugin, Result, RpcError, Server, Value, async_trait, json};
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
struct Hello { count: AtomicU64 }
#[async_trait]
impl Plugin for Hello {
async fn call(&self, context: Context, method: &str, _input: Value) -> Result<Value> {
match method {
"ui/hello" | "command/hello" => {
let count = self.count.fetch_add(1, Ordering::Relaxed) + 1;
context.log("info", "Hello command completed").await?;
Ok(json!({"message": "Hello from Rust", "count": count}))
}
_ => Err(RpcError::method_not_found(method)),
}
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { Server::from_env()?.serve(Hello::default()).await }
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NyaTerm plugin starter</title>
<style>
body {
margin: 16px;
font: 14px/1.5 system-ui;
background: var(--df-bg, #111827);
color: var(--df-fg, #e5e7eb);
}
button {
padding: 8px 12px;
font: inherit;
cursor: pointer;
}
pre {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>
</head>
<body>
<h1>Hello, NyaTerm</h1>
<button id="run" type="button">Run example</button>
<pre id="output" aria-live="polite"></pre>
<script src="nyaterm-sdk.js"></script>
<script src="main.js"></script>
</body>
</html>