plugin sdk
squash
This commit is contained in:
+328
-6
@@ -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
@@ -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
@@ -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(());
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user