feat(plugins): plugin system with extension points, consent, Wasm runtime and plugin API 1.0 (#971)

A plugin system built on WebAssembly components (wasmtime, WASI p2), with a
typed WIT API (`openidea:[email protected]`) and a Rust SDK, shipped as a preview.

- Extension points declared in `plugin.toml` (commands, languages, themes,
  icon themes, menus, key bindings, status items, settings, views, custom
  editors, file icons and templates, run detectors, editor providers),
  validated before anything takes effect and projected onto the workbench's
  own registries: plugin themes and file icon themes are chosen in
  Settings › Appearance and Quick Switch, plugin shortcuts are edited in
  Settings › Keymap, plugin problems are grouped in Settings › Inspections.
- Capabilities with risk levels and consent at install, pinned `process:`
  programs, secrets in the OS keychain.
- Runtime: one thread per active plugin, fuel/time/memory budgets, soft and
  hard deadlines on the editing path, cancellation, crash backoff, idle
  reclaim, a precompiled-component cache; remote projects run code plugins on
  `openidea-server`.
- Web views and custom editors with a sandboxed page, a document bridge
  joined to the text editor's undo history, and split or preview layouts.
- Distribution: signed `.oiplugin` packages, a static marketplace index with
  revocation, versioned installs with rollback, install from a `.zip` or a
  folder. The release workflow signs and publishes the official plugins and
  their index, which released builds use as the default marketplace.
- Official plugins: String Tools, Key Promoter, Git Toolbox, AI Completion,
  Checkstyle, MyBatis Navigator, CSV Editor, Solidity, Material Icons, Harbor
  Night, Excalidraw, Graphviz, Regex Tester, License Header.

Closes #732

Co-authored-by: Deng Liu <[email protected]>
Co-authored-by: florentvdry <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-04 02:54:31 +08:00
1 джерело cfdbd3872a
коміт 1c5a92f952
450 files changed
+47018 -3058

No files matched your search

+107 -3
Переглянути файл
@@ -90,6 +90,11 @@ impl Connections {
.map(|(label, _)| label.clone())
}
/// Every connected window.
pub fn all(&self) -> Vec<(String, Arc<Connection>)> {
self.0.lock().iter().map(|(w, c)| (w.clone(), c.clone())).collect()
}
pub fn disconnect(&self, window: &str) {
if let Some(c) = self.0.lock().remove(window) {
c.peer.kill();
@@ -157,6 +162,16 @@ impl Handler for ClientHandler {
}
Err(e) => tracing::warn!("remote event: {e}"),
},
// Plugin code of this window's project runs on the server.
ide_host::plugins::remote_host::EVENT_NOTIFICATION => {
match serde_json::from_value::<ide_host::plugins::remote_host::PluginEventMessage>(params) {
Ok(m) if ide_host::plugins::remote_host::forwarded(&m.event) => {
crate::commands::plugin::deliver_to(&self.window, m.event)
}
Ok(_) => {}
Err(e) => tracing::warn!("remote plugin event: {e}"),
}
}
"channel" => match serde_json::from_value::<ChannelMessage>(params) {
Ok(m) => {
let channel = self.channels.lock().get(&m.id).cloned();
@@ -170,8 +185,18 @@ impl Handler for ClientHandler {
}
}
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}")))
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
use ide_host::plugins::remote_host::{SECRET_REQUEST, SecretRequest};
match method {
// A remote plugin's request header needs a secret from this machine's keychain.
SECRET_REQUEST => {
let r: SecretRequest =
serde_json::from_value(params).map_err(|e| (ide_jsonrpc::codes::INVALID_PARAMS, e.to_string()))?;
let state = self.app.state::<AppState>();
Ok(serde_json::to_value(state.plugins.declared_secret(&r.plugin, &r.name)).unwrap_or(Value::Null))
}
_ => Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))),
}
}
}
@@ -401,7 +426,12 @@ pub async fn remote_connect(
next_channel: std::sync::atomic::AtomicU64::new(1),
_session: session,
});
state.remote_dev.0.lock().insert(label, connection);
state.remote_dev.0.lock().insert(label.clone(), connection.clone());
let plugins = state.plugins.clone();
tauri::async_runtime::spawn(async move {
sync_plugin_languages(&plugins, &connection, &label).await;
sync_plugin_packages(&plugins, &connection, &label).await;
});
Ok(RemoteProject {
ssh_config,
root,
@@ -413,6 +443,80 @@ pub async fn remote_connect(
})
}
/// Sends the desktop's plugin languages, with the programs granted to them, to
/// the window's server: `Workspace` contributions take effect where the project
/// is. Servers without the command are left alone.
pub async fn sync_plugin_languages(plugins: &ide_host::plugins::PluginHost, c: &Connection, window: &str) {
use ide_host::plugins::remote::{SYNC_COMMAND, SyncReport};
if !c.commands.iter().any(|cmd| cmd == SYNC_COMMAND) {
return;
}
let call = Invoke {
window: window.to_owned(),
cmd: SYNC_COMMAND.into(),
args: serde_json::json!({ "languages": plugins.remote_languages() }),
};
match c.peer.request::<_, SyncReport>("invoke", call, None).await {
Ok(report) => {
for program in &report.unresolved {
tracing::info!("{}: plugin program {program} is not installed", c.host);
}
for rejected in &report.rejected {
tracing::warn!("{}: plugin language rejected: {rejected}", c.host);
}
}
Err(e) => tracing::warn!("{}: syncing plugin languages: {e}", c.host),
}
}
/// Sends the desktop's live code plugins to the window's server, which runs
/// them next to the project: digests and grants first,
/// then the files of the packages the server lacks, one per request.
pub async fn sync_plugin_packages(plugins: &Arc<ide_host::plugins::PluginHost>, c: &Connection, window: &str) {
use ide_host::plugins::remote_host::{PackageSyncReport, SYNC_PACKAGES};
if !c.commands.iter().any(|cmd| cmd == SYNC_PACKAGES) {
return;
}
let host = plugins.clone();
let Ok(packages) = tauri::async_runtime::spawn_blocking(move || host.remote_packages()).await else { return };
let sync = |files: serde_json::Value| Invoke {
window: window.to_owned(),
cmd: SYNC_PACKAGES.into(),
args: serde_json::json!({ "packages": packages, "files": files }),
};
let report: PackageSyncReport = match c.peer.request("invoke", sync(Value::Null), None).await {
Ok(r) => r,
Err(e) => return tracing::warn!("{}: syncing plugins: {e}", c.host),
};
let mut failed = report.failed;
for id in report.missing {
let host = plugins.clone();
let pid = id.clone();
let files = match tauri::async_runtime::spawn_blocking(move || host.remote_package_files(&pid)).await {
Ok(Ok(files)) => files,
Ok(Err(e)) => {
failed.push(format!("{id}: {e}"));
continue;
}
Err(e) => {
failed.push(format!("{id}: {e}"));
continue;
}
};
match c
.peer
.request::<_, PackageSyncReport>("invoke", sync(serde_json::json!({ (id.clone()): files })), None)
.await
{
Ok(r) => failed.extend(r.failed),
Err(e) => failed.push(format!("{id}: {e}")),
}
}
for f in failed {
tracing::warn!("{}: plugin not available remotely: {f}", c.host);
}
}
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct RemoteSession {