plugin sdk

squash
This commit is contained in:
Gregor Lohaus
2026-09-17 14:27:57 +02:00
parent 9b9fc8517d
commit a5ac986375
21 changed files with 2562 additions and 10 deletions
+328 -6
View File
@@ -3,6 +3,8 @@ use crate::{
input,
layout::{Axis, Direction, Layout, PaneId, Tab},
pane::{self, Pane},
plugin_api::{Event, Operation, PaneState, State, TabState},
plugins::{self, Host},
theme::{self, Theme},
};
use anyhow::{Result, ensure};
@@ -22,6 +24,7 @@ pub struct App {
next_id: u64,
error: Option<String>,
suppressed: HashSet<egui::Key>,
plugins: Host,
}
/// Width kept free at the left of the tab bar for the macOS traffic lights,
@@ -69,6 +72,11 @@ impl App {
cc.egui_ctx.set_visuals(theme.visuals());
cc.egui_ctx.set_fonts(fonts(&config)?);
let pane = Pane::new(&config, &theme, &cc.egui_ctx, None)?;
let config_dir = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(std::path::Path::new("."));
let (plugins, errors) = Host::start(&config.plugins, config_dir, &cc.egui_ctx);
Ok(Self {
config,
theme,
@@ -77,8 +85,9 @@ impl App {
tabs: vec![Tab::new(1, 1)],
current: 0,
next_id: 2,
error: None,
error: (!errors.is_empty()).then(|| errors.join("\n")),
suppressed: HashSet::new(),
plugins,
})
}
@@ -292,6 +301,175 @@ impl App {
}
}
fn plugin_state(&self) -> State {
State {
active_tab: self.tabs.get(self.current).map(|tab| tab.id),
tabs: self
.tabs
.iter()
.map(|tab| TabState {
id: tab.id,
active_pane: tab.active,
floats_visible: tab.floats_visible,
zoomed: tab.zoomed,
panes: tab
.ids()
.iter()
.map(|id| PaneState {
id: *id,
floating: tab.floats.contains(id),
title: self.panes[id].terminal.title().unwrap_or("").into(),
exited: self.panes[id].dead,
})
.collect(),
})
.collect(),
}
}
fn plugin_request(
&mut self,
index: usize,
operation: Operation,
ctx: &egui::Context,
) -> Result<serde_json::Value> {
use serde_json::json;
match operation {
Operation::GetState {} => return Ok(serde_json::to_value(self.plugin_state())?),
Operation::Execute { action } => {
ensure!(!self.tabs.is_empty(), "no active tab");
self.action(action, ctx)?;
}
Operation::FocusPane { pane } => {
let index = self
.tabs
.iter()
.position(|tab| tab.ids().contains(&pane))
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
self.current = index;
let tab = &mut self.tabs[index];
tab.active = pane;
tab.floats_visible = tab.floats.contains(&pane);
}
Operation::FocusTab { tab } => {
self.current = self
.tabs
.iter()
.position(|candidate| candidate.id == tab)
.ok_or_else(|| anyhow::anyhow!("unknown tab {tab}"))?;
}
Operation::ReadTerminal { pane } => {
use libghostty_vt::fmt::{Formatter, FormatterOptions};
let pane = self
.panes
.get(&pane)
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?;
let bytes = Formatter::new(
&pane.terminal,
FormatterOptions::new().with_trim(true).with_unwrap(true),
)?
.format_alloc(None)?;
ensure!(
bytes.len() <= 128 * 1024,
"terminal snapshot exceeds 128 KiB"
);
return Ok(json!(String::from_utf8_lossy(&bytes)));
}
Operation::WriteTerminal { pane, text } => {
self.panes
.get_mut(&pane)
.ok_or_else(|| anyhow::anyhow!("unknown pane {pane}"))?
.send(text.into_bytes())?;
}
Operation::RegisterCommand { command } => {
ensure!(plugins::valid_id(&command.id), "invalid command id");
ensure!(
!command.title.trim().is_empty() && command.title.len() <= 200,
"invalid command title"
);
let commands = &mut self.plugins.runtimes[index].commands;
ensure!(
commands.contains_key(&command.id) || commands.len() < 64,
"command limit reached"
);
commands.insert(command.id.clone(), command);
}
Operation::UnregisterCommand { command } => {
self.plugins.runtimes[index].commands.remove(&command);
}
Operation::SetPanel { panel } => {
panel.validate()?;
let panels = &mut self.plugins.runtimes[index].panels;
ensure!(
panels.contains_key(&panel.id) || panels.len() < 8,
"panel limit reached"
);
panels.insert(panel.id.clone(), panel);
}
Operation::RemovePanel { panel } => {
self.plugins.runtimes[index].panels.remove(&panel);
}
Operation::ReportError { message } => {
self.error = Some(format!(
"Plugin {}: {message}",
self.plugins.runtimes[index].id
));
}
}
Ok(serde_json::Value::Null)
}
fn poll_plugins(&mut self, ctx: &egui::Context) {
use serde_json::json;
if self.plugins.runtimes.is_empty() {
return;
}
for index in 0..self.plugins.runtimes.len() {
// Bound work per frame even when a plugin continuously sends requests.
for count in 0..32 {
let Some(request) = self.plugins.runtimes[index].poll() else {
break;
};
let response = match request {
Ok(request) => {
let response = match self.plugin_request(index, request.operation, ctx) {
Ok(result) => json!({"result": result}),
Err(error) => json!({"error": error.to_string()}),
};
request
.reply
.try_send(response)
.map_err(|e| anyhow::anyhow!(e.to_string()))
}
Err(error) => Err(anyhow::anyhow!(error)),
};
if let Err(e) = response {
let plugin = &mut self.plugins.runtimes[index];
self.error = Some(format!("Plugin {}: {e}", plugin.id));
plugin.stop();
break;
}
if count == 31 {
ctx.request_repaint();
}
}
}
let state = self.plugin_state();
let serialized = serde_json::to_value(&state).unwrap();
for plugin in &mut self.plugins.runtimes {
if plugin.is_running() && plugin.last_state.as_ref() != Some(&serialized) {
if let Err(e) = plugin.send(Event::State {
state: state.clone(),
}) {
self.error = Some(format!("Plugin {}: {e}", plugin.id));
plugin.stop();
} else {
plugin.last_state = Some(serialized.clone());
}
}
}
}
fn keyboard(&mut self, ctx: &egui::Context) -> Result<()> {
let events = ctx.input(|i| i.events.clone());
let modifiers = ctx.input(|i| i.modifiers);
@@ -388,15 +566,24 @@ impl eframe::App for App {
for pane in self.panes.values_mut() {
pane.drain(ctx);
}
grab_keys(ctx);
if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string());
self.poll_plugins(ctx);
if self.tabs.is_empty() {
return;
}
// Native plugin text fields must receive typing without forwarding it to the PTY.
let editing_ui = ctx.memory(|m| m.focused().is_some_and(|id| id != egui::Id::new("keys")));
if !editing_ui {
grab_keys(ctx);
if let Err(e) = self.keyboard(ctx) {
self.error = Some(e.to_string());
}
}
if self.tabs.is_empty() {
return;
}
let mut action = None;
let mut picked = None;
let mut plugin_command = None;
egui::TopBottomPanel::top("tabs").show(ctx, |ui| {
ui.horizontal_wrapped(|ui| {
if cfg!(target_os = "macos") {
@@ -440,6 +627,20 @@ impl eframe::App for App {
}
}
});
if self.plugins.runtimes.iter().any(|p| !p.commands.is_empty()) {
ui.menu_button("Plugins", |ui| {
for (index, plugin) in self.plugins.runtimes.iter().enumerate() {
ui.menu_button(&plugin.id, |ui| {
for command in plugin.commands.values() {
if ui.button(&command.title).clicked() {
plugin_command = Some((index, command.id.clone()));
ui.close();
}
}
});
}
});
}
ui.menu_button("Settings", |ui| {
if ui.button("Reload config").clicked() {
action = Some(Action::ReloadConfig);
@@ -538,9 +739,18 @@ impl eframe::App for App {
}
}
let active = tab.active;
let active_visible = tab.is_visible(active);
for error in self.plugins.show_panels(ctx) {
self.error = Some(error);
}
if let Some((index, command)) = plugin_command
&& let Err(e) = self.plugins.invoke(index, &command)
{
self.error = Some(e.to_string());
}
for (id, pane) in &mut self.panes {
if let Err(e) =
pane.focus(*id == active && tab.is_visible(*id) && ctx.input(|i| i.focused))
if let Err(e) = pane
.focus(*id == active && active_visible && !editing_ui && ctx.input(|i| i.focused))
{
pane.error = Some(e.to_string());
}
@@ -728,6 +938,117 @@ fn pane_title(id: PaneId, pane: &Pane) -> String {
mod tests {
use super::*;
fn pump_plugins(app: &mut App, ctx: &egui::Context, done: impl Fn(&App) -> bool) {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
loop {
for pane in app.panes.values_mut() {
pane.drain(ctx);
}
app.poll_plugins(ctx);
assert!(app.error.is_none(), "plugin error: {:?}", app.error);
if done(app) {
break;
}
assert!(std::time::Instant::now() < deadline, "plugin timed out");
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[test]
fn embedded_plugin_controls_panes_and_native_ui() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
let path = std::env::temp_dir().join(format!("slerm-plugin-{}.js", std::process::id()));
std::fs::write(&path, r#"
__slerm.activate({
async activate(api) {
let rejected = false;
try { await api.focusPane({pane:999}); } catch (_) { rejected = true; }
if (!rejected) throw Error('missing validation');
await api.execute({action:'new_floating'});
const state = await api.getState();
if (state.tabs[0].panes.length !== 2) throw Error('wrong state');
await api.writeTerminal({pane:state.tabs[0].activePane, text:'echo SDK_INPUT\r'});
if (typeof await api.readTerminal({pane:1}) !== 'string') throw Error('wrong terminal result');
await api.setPanel({panel:{id:'test', title:'Tools', elements:[
{type:'button', id:'done', label:'Done'},
{type:'input', id:'name', label:'Name', value:'Slerm'}
]}});
await api.registerCommand({command:{id:'ready',title:'Ready'}});
},
async onEvent(event, api) {
if (event.event === 'command') await api.execute({action:'new_tab'});
if (event.event === 'ui') {
await api.removePanel({panel:event.data.panel});
await api.registerCommand({command:{id:'passed',title:'Passed'}});
}
}
}, 1);
"#).unwrap();
let (host, errors) = Host::start(
&[config::Plugin {
id: "test".into(),
path: path.clone(),
}],
std::path::Path::new("."),
&ctx,
);
std::fs::remove_file(path).unwrap();
assert!(errors.is_empty(), "{errors:?}");
app.plugins = host;
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].commands.contains_key("ready")
});
assert_eq!(app.tabs[0].floats.len(), 1);
assert_eq!(app.plugins.runtimes[0].panels.len(), 1);
let _ = ctx.run(egui::RawInput::default(), |ctx| {
assert!(app.plugins.show_panels(ctx).is_empty());
});
app.plugins.invoke(0, "ready").unwrap();
pump_plugins(&mut app, &ctx, |app| app.tabs.len() == 2);
app.plugins.runtimes[0]
.send(Event::Ui {
data: crate::plugin_api::UIEvent::Click {
panel: "test".into(),
control: "done".into(),
},
})
.unwrap();
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].commands.contains_key("passed")
});
assert!(app.plugins.runtimes[0].panels.is_empty());
}
#[test]
#[ignore = "build the example first: npm run example --prefix sdk"]
fn bundled_typescript_example_runs_in_boa() {
let ctx = egui::Context::default();
let mut app = test_app(&ctx);
let (host, errors) = Host::start(
&[config::Plugin {
id: "workspace".into(),
path: "sdk/dist/workspace.js".into(),
}],
std::path::Path::new(env!("CARGO_MANIFEST_DIR")),
&ctx,
);
assert!(errors.is_empty(), "{errors:?}");
app.plugins = host;
pump_plugins(&mut app, &ctx, |app| {
app.plugins.runtimes[0].panels.contains_key("workspace")
});
app.plugins.runtimes[0]
.send(Event::Ui {
data: crate::plugin_api::UIEvent::Click {
panel: "workspace".into(),
control: "scratch".into(),
},
})
.unwrap();
pump_plugins(&mut app, &ctx, |app| app.tabs[0].floats.len() == 1);
}
fn test_app(ctx: &egui::Context) -> App {
let config = Config {
shell: vec!["/bin/sh".into()],
@@ -745,6 +1066,7 @@ mod tests {
next_id: 2,
error: None,
suppressed: HashSet::new(),
plugins: Host::default(),
}
}
+23 -1
View File
@@ -34,6 +34,15 @@ pub struct Config {
pub kitty: Kitty,
pub multiplexer: Multiplexer,
pub keys: BTreeMap<String, Action>,
pub plugins: Vec<Plugin>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plugin {
pub id: String,
/// JavaScript bundle path, relative to the config file.
pub path: PathBuf,
}
#[derive(Clone, Debug, Deserialize)]
@@ -55,7 +64,7 @@ pub struct Multiplexer {
pub floating_max_height: f32,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq, ts_rs::TS)]
#[serde(rename_all = "snake_case")]
pub enum Action {
SplitRight,
@@ -127,6 +136,7 @@ impl Default for Config {
colors: BTreeMap::new(),
kitty: Kitty::default(),
multiplexer: Multiplexer::default(),
plugins: Vec::new(),
keys: [
("Alt+F", Action::ToggleFloats),
("Alt+M", Action::NewPane),
@@ -174,6 +184,18 @@ impl Config {
pub fn parse(text: &str) -> Result<Self> {
let config: Self = toml::from_str(text)?;
let mut plugin_ids = std::collections::HashSet::new();
for plugin in &config.plugins {
ensure!(
crate::plugins::valid_id(&plugin.id) && plugin_ids.insert(&plugin.id),
"plugin ids must be unique and contain only letters, digits, '.', '-' or '_'"
);
ensure!(
!plugin.path.as_os_str().is_empty(),
"plugin {} needs a bundle path",
plugin.id
);
}
ensure!(
config.font_path.is_none() || config.font_family.is_none(),
"set either font_family or font_path, not both"
+10 -1
View File
@@ -4,6 +4,8 @@ mod fonts;
mod input;
mod layout;
mod pane;
mod plugin_api;
mod plugins;
mod pty;
mod render;
#[cfg(test)]
@@ -29,13 +31,20 @@ fn main() -> Result<()> {
}
return Ok(());
}
"--generate-plugin-sdk" => {
let target =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
std::fs::write(&target, plugin_api::typescript())?;
println!("Generated {}", target.display());
return Ok(());
}
"--version" => {
println!("slerm {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
"--help" | "-h" => {
println!(
"slerm [--config PATH] [--check-config | --init-config | --list-themes]\n\nDefault config: {}",
"slerm [--config PATH] [--check-config | --init-config | --list-themes | --generate-plugin-sdk]\n\nDefault config: {}",
path.display()
);
return Ok(());
+163
View File
@@ -0,0 +1,163 @@
//! The source of truth for the plugin API and its generated TypeScript bindings.
use crate::config::Action;
use serde::{Deserialize, Serialize};
use ts_rs::TS;
pub const API_VERSION: u32 = 1;
// Declare each method, its parameters and its result once. The enum drives Rust
// dispatch; the generated result map drives the TypeScript client's signatures.
macro_rules! plugin_api {
($($variant:ident => $method:ident { $($(#[$attr:meta])* $arg:ident: $ty:ty),* } -> $result:ty;)+) => {
#[derive(Debug, Deserialize, TS)]
#[serde(tag = "method", rename_all = "camelCase", deny_unknown_fields)]
pub enum Operation {
$($variant { $($(#[$attr])* $arg: $ty),* },)+
}
#[derive(TS)]
#[allow(dead_code, non_snake_case)]
pub struct Results { $(pub $method: $result,)+ }
};
}
plugin_api! {
GetState => getState {} -> State;
Execute => execute { action: Action } -> ();
FocusPane => focusPane { #[ts(type = "number")] pane: u64 } -> ();
FocusTab => focusTab { #[ts(type = "number")] tab: u64 } -> ();
ReadTerminal => readTerminal { #[ts(type = "number")] pane: u64 } -> String;
WriteTerminal => writeTerminal { #[ts(type = "number")] pane: u64, text: String } -> ();
RegisterCommand => registerCommand { command: PluginCommand } -> ();
UnregisterCommand => unregisterCommand { command: String } -> ();
SetPanel => setPanel { panel: Panel } -> ();
RemovePanel => removePanel { panel: String } -> ();
ReportError => reportError { message: String } -> ();
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct State {
#[ts(type = "number | null")]
pub active_tab: Option<u64>,
pub tabs: Vec<TabState>,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(rename_all = "camelCase")]
pub struct TabState {
#[ts(type = "number")]
pub id: u64,
#[ts(type = "number")]
pub active_pane: u64,
pub floats_visible: bool,
pub zoomed: bool,
pub panes: Vec<PaneState>,
}
#[derive(Clone, Debug, Serialize, TS)]
pub struct PaneState {
#[ts(type = "number")]
pub id: u64,
pub title: String,
pub floating: bool,
pub exited: bool,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct PluginCommand {
pub id: String,
pub title: String,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(deny_unknown_fields)]
pub struct Panel {
pub id: String,
pub title: String,
pub elements: Vec<Element>,
}
#[derive(Clone, Debug, Deserialize, TS)]
#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)]
pub enum Element {
Text {
text: String,
},
Button {
id: String,
label: String,
},
Input {
id: String,
label: String,
value: String,
},
Separator,
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum UIEvent {
Click {
panel: String,
control: String,
},
Change {
panel: String,
control: String,
value: String,
},
}
#[derive(Clone, Debug, Serialize, TS)]
#[serde(tag = "event", rename_all = "camelCase")]
pub enum Event {
Command { command: String },
State { state: State },
Ui { data: UIEvent },
}
pub fn typescript() -> String {
let mut output =
String::from("// Generated by cargo run -- --generate-plugin-sdk. Do not edit.\n\n");
output.push_str(&format!(
"export const apiVersion = {API_VERSION} as const;\n\n"
));
let config = ts_rs::Config::default();
for declaration in [
Action::decl(&config),
Operation::decl(&config),
Results::decl(&config),
State::decl(&config),
TabState::decl(&config),
PaneState::decl(&config),
PluginCommand::decl(&config),
Panel::decl(&config),
Element::decl(&config),
UIEvent::decl(&config),
Event::decl(&config),
] {
output.push_str("export ");
output.push_str(&declaration);
output.push_str("\n\n");
}
output.push_str(r#"type Params<M extends Operation["method"]> = Omit<Extract<Operation, { method: M }>, "method">;
export type Slerm = {
[M in Operation["method"]]: (...args: keyof Params<M> extends never ? [] : [params: Params<M>]) => Promise<Results[M] extends null ? void : Results[M]>;
};
"#);
output
}
#[cfg(test)]
mod tests {
#[test]
fn generated_sdk_matches_rust_api() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("sdk/src/generated.ts");
assert_eq!(
std::fs::read_to_string(path).unwrap(),
super::typescript(),
"Regenerate bindings with cargo run -- --generate-plugin-sdk"
);
}
}
+457
View File
@@ -0,0 +1,457 @@
//! Embedded Boa runtimes, one worker thread per plugin. UI mutations stay on the UI thread.
use crate::{
config::Plugin,
plugin_api::{API_VERSION, Element, Event, Operation, Panel, PluginCommand, UIEvent},
};
use anyhow::{Context as _, Result, ensure};
use boa_engine::{
Context, JsNativeError, JsResult, JsValue, NativeFunction, Source,
job::{Job, JobExecutor},
js_string,
property::Attribute,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, VecDeque},
path::Path,
rc::Rc,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender, TryRecvError},
},
thread,
time::{Duration, Instant},
};
const MAX_MESSAGE: usize = 1024 * 1024;
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 80
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c))
}
pub struct Pending {
pub operation: Operation,
pub reply: SyncSender<Value>,
}
struct Bridge {
requests: SyncSender<Result<Pending, String>>,
repaint: egui::Context,
stopped: Arc<AtomicBool>,
}
fn host_call(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
if text.len() > MAX_MESSAGE {
return Err(JsNativeError::range()
.with_message("plugin request exceeds 1 MiB")
.into());
}
let operation = serde_json::from_str(&text)
.map_err(|e| JsNativeError::typ().with_message(e.to_string()))?;
let bridge = ctx.get_data::<Bridge>().unwrap();
let (reply, response) = mpsc::sync_channel(1);
bridge
.requests
.try_send(Ok(Pending { operation, reply }))
.map_err(|_| JsNativeError::error().with_message("plugin host is unavailable"))?;
bridge.repaint.request_repaint();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if bridge.stopped.load(Ordering::Relaxed) || Instant::now() >= deadline {
return Err(JsNativeError::error()
.with_message("plugin stopped or host request timed out")
.into());
}
match response.recv_timeout(Duration::from_millis(50)) {
Ok(value) => return Ok(js_string!(serde_json::to_string(&value).unwrap()).into()),
Err(mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => {
return Err(JsNativeError::error()
.with_message("plugin host disconnected")
.into());
}
}
}
}
fn host_error(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
let text = args
.first()
.unwrap_or(&JsValue::undefined())
.to_string(ctx)?
.to_std_string_escaped();
let bridge = ctx.get_data::<Bridge>().unwrap();
let _ = bridge.requests.try_send(Err(text));
bridge.repaint.request_repaint();
Ok(JsValue::undefined())
}
// This runtime exposes promises, but no timers or native async jobs. Bound each
// promise checkpoint as well as JS loops, so runaway callbacks can be stopped.
struct Jobs {
queue: RefCell<VecDeque<Job>>,
stopped: Arc<AtomicBool>,
}
impl JobExecutor for Jobs {
fn enqueue_job(self: Rc<Self>, job: Job, _: &mut Context) {
self.queue.borrow_mut().push_back(job);
}
fn run_jobs(self: Rc<Self>, ctx: &mut Context) -> JsResult<()> {
for _ in 0..10_000 {
if self.stopped.load(Ordering::Relaxed) {
return Err(JsNativeError::error().with_message("plugin stopped").into());
}
let Some(job) = self.queue.borrow_mut().pop_front() else {
return Ok(());
};
match job {
Job::PromiseJob(job) => {
job.call(ctx)?;
}
Job::GenericJob(job) => {
job.call(ctx)?;
}
Job::FinalizationRegistryCleanupJob(_) => {}
_ => {
return Err(JsNativeError::error()
.with_message("unsupported plugin job")
.into());
}
}
}
Err(JsNativeError::error()
.with_message("plugin promise job limit exceeded")
.into())
}
}
fn run(source: String, events: Receiver<Event>, bridge: Bridge) -> Result<()> {
let jobs = Rc::new(Jobs {
queue: RefCell::new(VecDeque::new()),
stopped: bridge.stopped.clone(),
});
let mut ctx = Context::builder()
.job_executor(jobs)
.build()
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.runtime_limits_mut().set_loop_iteration_limit(1_000_000);
ctx.runtime_limits_mut().set_recursion_limit(128);
ctx.insert_data(bridge);
let execute = |ctx: &mut Context, source: &str| -> Result<()> {
ctx.eval(Source::from_bytes(source))
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.run_jobs().map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(())
};
ctx.register_global_builtin_callable(
js_string!("__slerm_call"),
1,
NativeFunction::from_fn_ptr(host_call),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_builtin_callable(
js_string!("__slerm_error"),
1,
NativeFunction::from_fn_ptr(host_error),
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
ctx.register_global_property(
js_string!("__slerm_version"),
API_VERSION,
Attribute::READONLY,
)
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
execute(&mut ctx, include_str!("../sdk/runtime.js"))?;
execute(&mut ctx, &source)?;
while let Ok(event) = events.recv() {
if ctx
.get_data::<Bridge>()
.unwrap()
.stopped
.load(Ordering::Relaxed)
{
break;
}
execute(
&mut ctx,
&format!("__slerm_dispatch({});", serde_json::to_string(&event)?),
)?;
}
Ok(())
}
pub struct Runtime {
pub id: String,
pub commands: BTreeMap<String, PluginCommand>,
pub panels: BTreeMap<String, Panel>,
pub last_state: Option<Value>,
outgoing: Option<SyncSender<Event>>,
incoming: Option<Receiver<Result<Pending, String>>>,
stopped: Arc<AtomicBool>,
}
impl Runtime {
pub fn spawn(config: &Plugin, config_dir: &Path, ctx: &egui::Context) -> Result<Self> {
let path = config_dir.join(&config.path);
let source = std::fs::read_to_string(&path)
.with_context(|| format!("Cannot read plugin {} at {}", config.id, path.display()))?;
ensure!(
source.len() <= 4 * MAX_MESSAGE,
"plugin bundle exceeds 4 MiB"
);
let (outgoing, events) = mpsc::sync_channel(64);
let (requests, incoming) = mpsc::sync_channel(64);
let stopped = Arc::new(AtomicBool::new(false));
let bridge = Bridge {
requests: requests.clone(),
repaint: ctx.clone(),
stopped: stopped.clone(),
};
let repaint = ctx.clone();
thread::Builder::new()
.name(format!("plugin-{}", config.id))
.spawn(move || {
if let Err(error) = run(source, events, bridge) {
let _ = requests.try_send(Err(error.to_string()));
repaint.request_repaint();
}
})?;
Ok(Self {
id: config.id.clone(),
commands: BTreeMap::new(),
panels: BTreeMap::new(),
last_state: None,
outgoing: Some(outgoing),
incoming: Some(incoming),
stopped,
})
}
pub fn poll(&mut self) -> Option<Result<Pending, String>> {
match self.incoming.as_ref()?.try_recv() {
Ok(request) => Some(request),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("plugin runtime stopped".into())),
}
}
pub fn send(&self, event: Event) -> Result<()> {
let outgoing = self
.outgoing
.as_ref()
.ok_or_else(|| anyhow::anyhow!("plugin is stopped"))?;
ensure!(
serde_json::to_vec(&event)?.len() < MAX_MESSAGE,
"plugin event exceeds 1 MiB"
);
outgoing
.try_send(event)
.context("plugin event queue is full or disconnected")
}
pub fn is_running(&self) -> bool {
!self.stopped.load(Ordering::Relaxed)
}
pub fn stop(&mut self) {
self.stopped.store(true, Ordering::Relaxed);
self.commands.clear();
self.panels.clear();
self.outgoing.take();
self.incoming.take();
}
}
impl Drop for Runtime {
fn drop(&mut self) {
self.stop();
}
}
impl Panel {
pub fn validate(&self) -> Result<()> {
ensure!(valid_id(&self.id), "invalid panel id");
ensure!(
!self.title.trim().is_empty() && self.title.len() <= 200,
"invalid panel title"
);
ensure!(
self.elements.len() <= 128,
"a panel supports at most 128 elements"
);
let mut ids = std::collections::HashSet::new();
for element in &self.elements {
let id = match element {
Element::Button { id, .. } | Element::Input { id, .. } => Some(id),
_ => None,
};
if let Some(id) = id {
ensure!(
valid_id(id) && ids.insert(id),
"invalid or duplicate control id"
);
}
}
Ok(())
}
}
#[derive(Default)]
pub struct Host {
pub runtimes: Vec<Runtime>,
}
impl Host {
pub fn start(
configs: &[Plugin],
config_dir: &Path,
ctx: &egui::Context,
) -> (Self, Vec<String>) {
let mut host = Self::default();
let mut errors = Vec::new();
for config in configs {
match Runtime::spawn(config, config_dir, ctx) {
Ok(process) => host.runtimes.push(process),
Err(e) => errors.push(e.to_string()),
}
}
(host, errors)
}
pub fn invoke(&self, index: usize, command: &str) -> Result<()> {
let plugin = &self.runtimes[index];
ensure!(
plugin.commands.contains_key(command),
"unknown plugin command"
);
plugin.send(Event::Command {
command: command.into(),
})
}
pub fn show_panels(&mut self, ctx: &egui::Context) -> Vec<String> {
let mut errors = Vec::new();
for plugin in &mut self.runtimes {
let mut events = Vec::new();
for panel in plugin.panels.values_mut() {
egui::Window::new(&panel.title)
.id(egui::Id::new(("plugin panel", &plugin.id, &panel.id)))
.order(egui::Order::Foreground)
.default_width(280.0)
.show(ctx, |ui| {
egui::ScrollArea::vertical()
.max_height(500.0)
.show(ui, |ui| {
for element in &mut panel.elements {
match element {
Element::Text { text } => {
ui.label(text.as_str());
}
Element::Separator => {
ui.separator();
}
Element::Button { id, label } => {
if ui
.push_id(id.as_str(), |ui| {
ui.button(label.as_str())
})
.inner
.clicked()
{
events.push(Event::Ui {
data: UIEvent::Click {
panel: panel.id.clone(),
control: id.clone(),
},
});
}
}
Element::Input { id, label, value } => {
ui.label(label.as_str());
if ui
.add(
egui::TextEdit::singleline(value)
.id_source(id.as_str()),
)
.changed()
{
events.push(Event::Ui {
data: UIEvent::Change {
panel: panel.id.clone(),
control: id.clone(),
value: value.clone(),
},
});
}
}
}
}
});
});
}
for event in events {
if let Err(e) = plugin.send(event) {
errors.push(format!("{}: {e}", plugin.id));
}
}
}
errors
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn rejects_invalid_configuration_and_ui() {
assert!(crate::config::Config::parse("[[plugins]]\nid='x'\npath=''\n").is_err());
assert!(
crate::config::Config::parse(
"[[plugins]]\nid='x'\npath='a.js'\n[[plugins]]\nid='x'\npath='b.js'\n"
)
.is_err()
);
assert!(serde_json::from_str::<Operation>(r#"{"method":"unknown"}"#).is_err());
let panel: Panel =
serde_json::from_value(json!({"id":"panel", "title":"Panel", "elements":[
{"type":"button", "id":"same", "label":"One"},
{"type":"input", "id":"same", "label":"Two", "value":""}
]}))
.unwrap();
assert!(panel.validate().is_err());
}
#[test]
fn boa_reports_script_errors_and_limits_runaway_loops_and_promises() {
for source in [
"throw Error('broken plugin')",
"while (true) {}",
"function again() { Promise.resolve().then(again); } again();",
"__slerm.activate({}, 999)",
"process.exit(0)",
] {
let (_, events) = mpsc::sync_channel(1);
let (requests, _) = mpsc::sync_channel(1);
let bridge = Bridge {
requests,
repaint: egui::Context::default(),
stopped: Arc::new(AtomicBool::new(false)),
};
assert!(
run(source.into(), events, bridge).is_err(),
"source should fail: {source}"
);
}
}
}