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

+2 -8
Переглянути файл
@@ -126,14 +126,8 @@ pub fn close_project(window: Window, state: State<'_, AppState>) {
#[specta::specta]
pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> {
state.note_ipc();
let mut out: Vec<LanguageInfo> = state.languages.all().iter().map(LanguageInfo::from).collect();
// Plugin-contributed languages; built-in definitions win on id clashes.
for lang in state.plugins.languages() {
if !out.iter().any(|l| l.id == lang.id) {
out.push(LanguageInfo::from(&lang));
}
}
out
// Includes plugin languages (built-in definitions win on id clashes).
state.languages.all().iter().map(LanguageInfo::from).collect()
}
/// Effective settings for the window's project, or global settings without one.
+25
Переглянути файл
@@ -419,11 +419,36 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
plugin::plugin_subscribe,
plugin::plugin_list,
plugin::plugin_set_enabled,
plugin::plugin_preview_install,
plugin::plugin_install_dir,
plugin::plugin_grant,
plugin::plugin_set_capability,
plugin::plugin_link,
plugin::plugin_unlink,
plugin::plugin_assets,
plugin::plugin_activation_event,
plugin::plugin_catalog,
plugin::plugin_uninstall,
plugin::plugin_reload,
plugin::plugin_run_command,
plugin::plugin_post_events,
plugin::plugin_event_kinds,
plugin::plugin_status_items,
plugin::plugin_cancel_task,
plugin::plugin_view_render,
plugin::plugin_view_event,
plugin::plugin_web_page,
plugin::plugin_web_save_file,
plugin::plugin_web_read_file,
plugin::plugin_line_markers,
plugin::plugin_market,
plugin::plugin_market_url,
plugin::plugin_set_market_url,
plugin::plugin_market_download,
plugin::plugin_unpack_package,
plugin::plugin_roll_back,
plugin::plugin_secrets,
plugin::plugin_set_secret,
plugin::plugin_set_active_file,
plugin::plugin_set_theme,
plugin::plugin_set_theme_frame,
+458 -30
Переглянути файл
@@ -1,13 +1,18 @@
//! WASM plugins (`ide-plugin`). Each window subscribes once with
//! `plugin_subscribe(channel)` and receives [`PluginEvent`]s addressed to it
//! (insert-text requests, notifications) plus broadcasts (list/theme changes).
//! Plugins: Tauri adapter over `ide_host::plugins::PluginHost`. Each window
//! subscribes once with `plugin_subscribe(channel)` and receives
//! [`PluginEvent`]s addressed to it (notifications, consent requests, tasks)
//! plus broadcasts (list/theme/status item changes).
use crate::state::AppState;
use ide_core::{IdeError, IdeResult};
use ide_plugin::{
ActiveTheme, CatalogPlugin, Invocation, Limits, PluginEvent, PluginHost, PluginInfo, PluginsSnapshot,
use ide_extension::ActivationEvent;
use ide_host::plugins::{
ActiveTheme, CatalogPlugin, Consent, EventKind, HostWorkspace, InstallPreview, PluginEditor, PluginEvent,
PluginHost, PluginInfo, PluginStatusItem, PluginsSnapshot, WorkbenchEvent,
};
use ide_plugin::{Invocation, Limits};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
@@ -17,6 +22,16 @@ use tauri::{AppHandle, Manager, State, Window};
/// How long `plugin_list` waits for the startup load before answering with what it has.
const INITIAL_LOAD_WAIT: Duration = Duration::from_secs(5);
/// `on-startup-finished` fires this long after the installed plugins are loaded.
const STARTUP_FINISHED_DELAY: Duration = Duration::from_millis(1500);
/// How often the marketplace index is read again (revocations).
const MARKET_REFRESH: Duration = Duration::from_secs(24 * 60 * 60);
/// Period of [`PluginHost::flush_events`]; [`PluginHost::tick`] runs every
/// [`TICKS_PER_SUPERVISION`] of them.
const EVENT_FLUSH: Duration = Duration::from_millis(250);
const TICKS_PER_SUPERVISION: u32 = 20;
/// Plugin changes (install, enable, grants) reach remote servers after this quiet time.
const REMOTE_SYNC_DELAY: Duration = Duration::from_millis(800);
/// Window label → its event channel.
static SUBSCRIBERS: LazyLock<Mutex<HashMap<String, Channel<PluginEvent>>>> = LazyLock::new(Default::default);
@@ -35,19 +50,104 @@ fn deliver(client: Option<&str>, event: PluginEvent) {
}
}
/// Creates the host over `<app-data>/plugins` and loads installed plugins in the background.
pub fn create_host(
_app: &AppHandle,
data_dir: &Path,
project_host: &Arc<ide_host::Host>,
) -> IdeResult<Arc<PluginHost>> {
let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(deliver), Limits::default())?);
/// Delivers a plugin event to one window (remote windows: from their server).
pub(crate) fn deliver_to(window: &str, event: PluginEvent) {
deliver(Some(window), event);
}
/// Re-syncs remote servers' plugins once plugin changes settle.
fn schedule_remote_sync(app: &AppHandle) {
static SCHEDULED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if SCHEDULED.swap(true, std::sync::atomic::Ordering::AcqRel) {
return;
}
let app = app.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(REMOTE_SYNC_DELAY).await;
SCHEDULED.store(false, std::sync::atomic::Ordering::Release);
let Some(state) = app.try_state::<crate::state::AppState>() else { return };
for (window, connection) in state.remote_dev.all() {
crate::remote_dev::sync_plugin_packages(&state.plugins, &connection, &window).await;
}
});
}
/// Creates the host over `<app-data>/plugins` and loads installed plugins in
/// the background (no Wasm is instantiated until an activation event).
pub fn create_host(app: &AppHandle, data_dir: &Path, project_host: &Arc<ide_host::Host>) -> IdeResult<Arc<PluginHost>> {
let projects = Arc::downgrade(project_host);
let app = app.clone();
let sink = move |client: Option<&str>, event: PluginEvent| {
if matches!(event, PluginEvent::CommandsChanged)
&& app.try_state::<crate::state::AppState>().is_some_and(|s| !s.remote_dev.all().is_empty())
{
schedule_remote_sync(&app);
}
if matches!(event, PluginEvent::LanguagesChanged) {
if let Some(projects) = projects.upgrade() {
projects.plugin_languages_changed();
}
// Remote windows: their servers run the languages.
if let Some(state) = app.try_state::<crate::state::AppState>() {
let plugins = state.plugins.clone();
for (window, connection) in state.remote_dev.all() {
let plugins = plugins.clone();
tauri::async_runtime::spawn(async move {
crate::remote_dev::sync_plugin_languages(&plugins, &connection, &window).await
});
}
}
}
deliver(client, event);
};
let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(sink), Limits::default())?);
// Plugin languages join detection, LSP and DAP of every project (forks share the store),
// and the syntax of the ones without a built-in grammar comes from their plugin.
project_host.languages.attach_plugins(host.language_store().clone());
ide_language::set_external_syntax(Some(host.syntax_provider()));
ide_run::set_external_detection(Some(host.run_detection()));
host.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(project_host)))));
// Released builds list the official plugins (published with each release) until the user picks another index.
if !cfg!(debug_assertions) {
host.set_default_market_url(Some(ide_host::plugins::OFFICIAL_MARKET_INDEX.into()));
}
#[cfg(target_os = "macos")]
host.set_secret_store(Arc::new(KeychainSecrets));
ide_host::plugins::set_editor_providers(Some(&host));
let weak = Arc::downgrade(project_host);
host.set_project_policy(Arc::new(move |root| {
weak.upgrade().is_some_and(|host| host.trust.trusted(root).unwrap_or(false))
}));
let loader = host.clone();
std::thread::Builder::new().name("plugin-load".into()).spawn(move || loader.load_all())?;
let loader = Arc::downgrade(&host);
std::thread::Builder::new().name("plugin-load".into()).spawn(move || {
let Some(host) = loader.upgrade() else { return };
host.load_all();
drop(host);
std::thread::sleep(STARTUP_FINISHED_DELAY);
let Some(host) = loader.upgrade() else { return };
host.startup_finished();
host.warm_component_cache();
drop(host);
// Event delivery; supervision (crash reports, idle reclaim, `on-schedule`);
// the daily marketplace check (revocations).
let mut market_due = std::time::Instant::now() + Duration::from_secs(60);
for n in 1u32.. {
std::thread::sleep(EVENT_FLUSH);
let Some(host) = loader.upgrade() else { return };
host.flush_events();
if n % TICKS_PER_SUPERVISION == 0 {
host.tick();
}
if std::time::Instant::now() >= market_due {
market_due = std::time::Instant::now() + MARKET_REFRESH;
if host.market_url().is_some()
&& let Err(e) = host.refresh_market()
{
tracing::info!("plugin marketplace: {e}");
}
}
}
})?;
Ok(host)
}
@@ -66,8 +166,9 @@ pub fn plugin_subscribe(window: Window, on_event: Channel<PluginEvent>) {
SUBSCRIBERS.lock().insert(window.label().to_owned(), on_event);
}
/// Installed plugins plus the dev plugins of this window's project
/// (`<project>/.openidea/plugins/*`, rescanned on every call).
/// Installed and linked plugins plus the project plugins of this window's
/// project (`<project>/.openidea/plugins/*`, rescanned on every call), with
/// the UI contributions to project.
#[tauri::command]
#[specta::specta]
pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResult<PluginsSnapshot> {
@@ -76,6 +177,7 @@ pub async fn plugin_list(window: Window, state: State<'_, AppState>) -> IdeResul
blocking(move || {
// A window opened at startup would otherwise see no plugins and drop the selected theme.
host.wait_initial_load(INITIAL_LOAD_WAIT);
host.sync_links();
if let Some(root) = &root {
host.sync_project(root);
}
@@ -91,25 +193,110 @@ pub async fn plugin_set_enabled(state: State<'_, AppState>, id: String, enabled:
blocking(move || host.set_enabled(&id, enabled)).await
}
/// Installs the plugin package at `path`: a folder, its `plugin.toml`, or a `.zip` of the folder.
/// Validates the package in `path` for the install page (nothing is copied).
#[tauri::command]
#[specta::specta]
pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> {
pub async fn plugin_preview_install(state: State<'_, AppState>, path: PathBuf) -> IdeResult<InstallPreview> {
let host = state.plugins.clone();
blocking(move || host.install_from_path(&path)).await
blocking(move || host.preview_install(&path)).await
}
/// Installs the plugin package in `path` (a folder with `plugin.toml`) with
/// the capabilities the user accepted on the install page.
#[tauri::command]
#[specta::specta]
pub async fn plugin_install_dir(state: State<'_, AppState>, path: PathBuf, consent: Consent) -> IdeResult<PluginInfo> {
let host = state.plugins.clone();
blocking(move || host.install_from_dir(&path, &consent)).await
}
/// Confirms a plugin waiting for consent (project plugins, new capabilities).
#[tauri::command]
#[specta::specta]
pub async fn plugin_grant(state: State<'_, AppState>, id: String, consent: Consent) -> IdeResult<PluginInfo> {
let host = state.plugins.clone();
blocking(move || host.grant(&id, &consent)).await
}
/// Grants or revokes one declared capability.
#[tauri::command]
#[specta::specta]
pub async fn plugin_set_capability(
state: State<'_, AppState>,
id: String,
capability: String,
granted: bool,
) -> IdeResult<PluginInfo> {
let host = state.plugins.clone();
blocking(move || host.set_capability(&id, &capability, granted)).await
}
/// Links a development plugin folder (`openidea-plugin link` does the same from a terminal).
#[tauri::command]
#[specta::specta]
pub async fn plugin_link(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PluginInfo> {
let host = state.plugins.clone();
blocking(move || host.link(&path)).await
}
#[tauri::command]
#[specta::specta]
pub async fn plugin_unlink(state: State<'_, AppState>, id: String) -> IdeResult<()> {
let host = state.plugins.clone();
blocking(move || host.unlink(&id)).await
}
/// Validated assets of a plugin whose id starts with `prefix` (icon theme mapping and SVGs).
#[tauri::command]
#[specta::specta]
pub async fn plugin_assets(
state: State<'_, AppState>,
plugin_id: String,
prefix: String,
) -> IdeResult<Vec<(String, String)>> {
let host = state.plugins.clone();
blocking(move || host.assets(&plugin_id, &prefix)).await
}
/// An activation event from the UI: `kind` is `language`, `file` (project-relative path) or `view`.
#[tauri::command]
#[specta::specta]
pub async fn plugin_activation_event(
window: Window,
state: State<'_, AppState>,
kind: String,
value: String,
) -> IdeResult<()> {
let event = match kind.as_str() {
"language" => ActivationEvent::Language(value),
"file" => ActivationEvent::File(value),
"view" => ActivationEvent::View(value),
_ => return Err(IdeError::invalid(format!("unknown activation event kind {kind:?}"))),
};
let project = state.project(window.label()).ok();
if project.as_ref().is_some_and(|p| !p.is_trusted()) {
return Ok(()); // Restricted Mode: no plugin code runs for this project.
}
let host = state.plugins.clone();
blocking(move || {
host.activation_event(&event, project.as_ref().map(|p| p.root.as_path()));
Ok(())
})
.await
}
/// Plugin packages available locally for installation: `<resources>/plugins`
/// when bundled, plus the repository's sample `plugins/` in debug builds.
/// There is no network marketplace.
/// when bundled, plus the repository's official plugins in debug builds.
#[tauri::command]
#[specta::specta]
pub async fn plugin_catalog(app: AppHandle) -> IdeResult<Vec<CatalogPlugin>> {
pub async fn plugin_catalog(app: AppHandle, state: State<'_, AppState>) -> IdeResult<Vec<CatalogPlugin>> {
let mut dirs: Vec<PathBuf> = app.path().resource_dir().map(|d| vec![d.join("plugins")]).unwrap_or_default();
if cfg!(debug_assertions) {
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../plugins"));
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
dirs.push(repo.join("plugins/official"));
}
blocking(move || Ok(ide_plugin::scan_catalog(&dirs))).await
let host = state.plugins.clone();
blocking(move || Ok(host.catalog(&dirs))).await
}
#[tauri::command]
@@ -127,7 +314,7 @@ pub async fn plugin_reload(state: State<'_, AppState>, id: String) -> IdeResult<
blocking(move || host.reload(&id)).await
}
/// Runs a plugin command in the context of this window's project.
/// Runs a plugin command in the context of this window's project and focused editor.
#[tauri::command]
#[specta::specta]
pub async fn plugin_run_command(
@@ -136,21 +323,262 @@ pub async fn plugin_run_command(
plugin_id: String,
command_id: String,
arg: Option<String>,
editor: Option<PluginEditor>,
) -> IdeResult<Option<String>> {
let host = state.plugins.clone();
let project = state.project(window.label()).ok();
let invocation = Invocation {
client: Some(window.label().to_owned()),
workspace_root: project.as_ref().map(|p| p.root.clone()),
buffer_text: project.map(|p| {
let documents = p.documents.clone();
Arc::new(move |path: &Path| documents.text_of(path)) as ide_plugin::BufferText
}),
editor: editor.map(PluginEditor::context),
};
blocking(move || host.run_command(&plugin_id, &command_id, arg.as_deref(), invocation)).await
}
/// The UI reports the focused editor's file (`active-file` for plugins).
/// Workbench events from this window (only the kinds of `plugin_event_kinds`).
#[tauri::command]
#[specta::specta]
pub fn plugin_post_events(window: Window, state: State<'_, AppState>, events: Vec<WorkbenchEvent>) {
state.plugins.post_events(Some(window.label()), events);
}
/// Kinds of workbench events plugins want (re-fetch on `subscriptionsChanged`).
#[tauri::command]
#[specta::specta]
pub async fn plugin_event_kinds(app: tauri::AppHandle) -> Vec<EventKind> {
let host = app.state::<AppState>().plugins.clone();
// Asked once at startup: before the first load, the declared `on-event:` kinds
// would be missing, and the `commandsChanged` that load sends may precede the window's subscription.
let kinds = tauri::async_runtime::spawn_blocking(move || {
host.wait_initial_load(INITIAL_LOAD_WAIT);
host.event_kinds().into_iter().collect()
});
kinds.await.unwrap_or_default()
}
/// Every plugin status bar item (later changes arrive as `statusItemChanged`).
#[tauri::command]
#[specta::specta]
pub fn plugin_status_items(state: State<'_, AppState>) -> Vec<PluginStatusItem> {
state
.plugins
.status_items()
.into_iter()
.map(|(plugin_id, id, item)| PluginStatusItem { plugin_id, id, item })
.collect()
}
/// Cancels a plugin task (`taskStarted` with `cancellable`).
#[tauri::command]
#[specta::specta]
pub fn plugin_cancel_task(state: State<'_, AppState>, task_id: String) -> bool {
state.plugins.cancel_task(&task_id)
}
/// Gutter markers of plugin `line-markers` providers for an open document.
#[tauri::command]
#[specta::specta]
pub async fn plugin_line_markers(
window: Window,
state: State<'_, AppState>,
doc: ide_core::DocId,
) -> IdeResult<Vec<ide_host::plugins::LineMarkerInfo>> {
ide_host::ops::lsp::plugin_line_markers(&state.host, window.label(), doc).await
}
/// Content of a plugin view (`doc`: the document of a custom editor; `editor`: for
/// a tool window, the window's active editor, as commands get it).
#[tauri::command]
#[specta::specta]
pub async fn plugin_view_render(
window: Window,
state: State<'_, AppState>,
plugin_id: String,
view: String,
viewport: Option<ide_plugin::Viewport>,
doc: Option<ide_core::DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<ide_plugin::View> {
let plugins = state.plugins.clone();
ide_host::plugins::views::render(&state.host, plugins, window.label(), plugin_id, view, viewport, doc, editor).await
}
/// A user action in a plugin view or custom editor (`doc`, `editor`: as for rendering).
#[tauri::command]
#[specta::specta]
pub async fn plugin_view_event(
window: Window,
state: State<'_, AppState>,
plugin_id: String,
view: String,
event: ide_plugin::ViewEvent,
doc: Option<ide_core::DocId>,
editor: Option<PluginEditor>,
) -> IdeResult<()> {
let plugins = state.plugins.clone();
ide_host::plugins::views::event(&state.host, plugins, window.label(), plugin_id, view, event, doc, editor).await
}
/// The page of a plugin's web view or custom editor (`web = …`).
#[tauri::command]
#[specta::specta]
pub fn plugin_web_page(
state: State<'_, AppState>,
plugin_id: String,
view: String,
) -> IdeResult<ide_host::plugins::web::WebPage> {
state.plugins.web_page(&plugin_id, &view)
}
/// Largest file a web view saves or opens (`openidea.saveFile` / `openFile`).
const MAX_WEB_VIEW_FILE: u64 = 64 << 20;
/// Writes what a web view saves (`openidea.saveFile`) to the path the user
/// chose in the save dialog, on this machine.
#[tauri::command]
#[specta::specta]
pub async fn plugin_web_save_file(path: PathBuf, base64: String) -> IdeResult<()> {
use base64::Engine;
if base64.len() as u64 > MAX_WEB_VIEW_FILE / 3 * 4 + 4 {
return Err(IdeError::invalid(format!("larger than {} MiB", MAX_WEB_VIEW_FILE >> 20)));
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(base64)
.map_err(|e| IdeError::invalid(format!("not base64: {e}")))?;
tokio::fs::write(&path, bytes)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))
}
/// Reads the file the user picked in an open dialog for a web view
/// (`openidea.openFile`), on this machine; base64.
#[tauri::command]
#[specta::specta]
pub async fn plugin_web_read_file(path: PathBuf) -> IdeResult<String> {
use base64::Engine;
let meta = tokio::fs::metadata(&path)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))?;
if !meta.is_file() || meta.len() > MAX_WEB_VIEW_FILE {
return Err(IdeError::invalid(format!(
"{}: not a file of at most {} MiB",
path.display(),
MAX_WEB_VIEW_FILE >> 20
)));
}
let bytes = tokio::fs::read(&path)
.await
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::Io, format!("{}: {e}", path.display())))?;
Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
}
/// The marketplace's plugins: read again when `refresh`, else the last index read.
#[tauri::command]
#[specta::specta]
pub async fn plugin_market(
state: State<'_, AppState>,
refresh: bool,
) -> IdeResult<Vec<ide_host::plugins::MarketPlugin>> {
let host = state.plugins.clone();
blocking(move || if refresh { host.refresh_market() } else { Ok(host.market_plugins()) }).await
}
#[tauri::command]
#[specta::specta]
pub fn plugin_market_url(state: State<'_, AppState>) -> Option<String> {
state.plugins.market_url()
}
/// Sets the marketplace index URL (an `https://…/index.json` or a local path; `None` for the default).
#[tauri::command]
#[specta::specta]
pub fn plugin_set_market_url(state: State<'_, AppState>, url: Option<String>) {
state.plugins.set_market_url(url);
}
/// Downloads and checks a marketplace plugin; returns the folder for the install page.
#[tauri::command]
#[specta::specta]
pub async fn plugin_market_download(state: State<'_, AppState>, id: String) -> IdeResult<PathBuf> {
let host = state.plugins.clone();
blocking(move || host.download_market_plugin(&id)).await
}
/// Unpacks a package file (`.oiplugin` or `.zip`); returns the folder for the install page.
#[tauri::command]
#[specta::specta]
pub async fn plugin_unpack_package(state: State<'_, AppState>, path: PathBuf) -> IdeResult<PathBuf> {
let host = state.plugins.clone();
blocking(move || host.unpack_package(&path)).await
}
/// Goes back to the version the last update of `id` replaced.
#[tauri::command]
#[specta::specta]
pub async fn plugin_roll_back(state: State<'_, AppState>, id: String) -> IdeResult<PluginInfo> {
let host = state.plugins.clone();
blocking(move || host.roll_back_update(&id)).await
}
/// A secret a plugin declares, and whether the user stored it.
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct PluginSecretState {
pub name: String,
pub set: bool,
}
#[tauri::command]
#[specta::specta]
pub fn plugin_secrets(state: State<'_, AppState>, plugin_id: String) -> IdeResult<Vec<PluginSecretState>> {
Ok(state.plugins.secrets(&plugin_id)?.into_iter().map(|(name, set)| PluginSecretState { name, set }).collect())
}
/// Stores a plugin secret (`None` or empty deletes it). The value never goes back to the UI or the plugin.
#[tauri::command]
#[specta::specta]
pub fn plugin_set_secret(
state: State<'_, AppState>,
plugin_id: String,
name: String,
value: Option<String>,
) -> IdeResult<()> {
state.plugins.set_secret(&plugin_id, &name, value.as_deref())
}
/// Plugin secrets in the login keychain (service `ai.l0ng.openidea.plugin.<id>`).
#[cfg(target_os = "macos")]
struct KeychainSecrets;
#[cfg(target_os = "macos")]
impl ide_host::plugins::SecretStore for KeychainSecrets {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
let bytes = security_framework::passwords::get_generic_password(&Self::service(plugin), name).ok()?;
String::from_utf8(bytes).ok()
}
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let service = Self::service(plugin);
match value {
Some(v) => security_framework::passwords::set_generic_password(&service, name, v.as_bytes()),
None => match security_framework::passwords::delete_generic_password(&service, name) {
// Deleting what isn't there is fine.
Err(e) if e.code() == -25300 => Ok(()),
other => other,
},
}
.map_err(|e| e.to_string())
}
}
#[cfg(target_os = "macos")]
impl KeychainSecrets {
fn service(plugin: &str) -> String {
format!("ai.l0ng.openidea.plugin.{plugin}")
}
}
/// The UI reports the focused editor's file (`editor-focused` events).
#[tauri::command]
#[specta::specta]
pub fn plugin_set_active_file(window: Window, state: State<'_, AppState>, path: Option<String>) {
+3
Переглянути файл
@@ -14,6 +14,7 @@ mod logging;
#[cfg(target_os = "macos")]
mod macos;
mod menu;
mod plugin_web;
mod remote_dev;
mod state;
@@ -91,6 +92,8 @@ pub fn run() {
let app = app
// Inert until an endpoint is configured (see `commands::window::app_check_update`).
.plugin(tauri_plugin_updater::Builder::new().build());
// Files of plugins' web views, for their sandboxed frames.
let app = app.register_asynchronous_uri_scheme_protocol(plugin_web::SCHEME, plugin_web::handle);
// E2E automation bridge (Tauri MCP), debug builds only, localhost only.
#[cfg(debug_assertions)]
let app = app.plugin(tauri_plugin_mcp_bridge::Builder::new().bind_address("127.0.0.1").build());
+4 -1
Переглянути файл
@@ -169,6 +169,8 @@ const SEP: Entry = Entry::Separator;
/// [`Entry::Dynamic`] slots the frontend fills.
pub const DYNAMIC_TOOL_WINDOWS: &str = "toolWindows";
pub const DYNAMIC_LAYOUTS: &str = "toolWindowLayouts";
/// Tools › entries of plugins (`[[contributes.menus]] menu = "tools"`).
pub const DYNAMIC_PLUGIN_TOOLS: &str = "pluginTools";
/// The menu bar for `os`. Labels are English defaults; the frontend replaces
/// them with `menu.<id>` strings from the active locale catalog.
@@ -725,6 +727,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
),
SEP,
act("vim.toggle", "Vim"),
Entry::Dynamic(DYNAMIC_PLUGIN_TOOLS),
],
));
@@ -1484,7 +1487,7 @@ mod tests {
let spec = spec(os);
let mut found = Vec::new();
slots(&spec, &mut found);
assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_LAYOUTS], "{os:?}");
assert_eq!(found, [DYNAMIC_TOOL_WINDOWS, DYNAMIC_PLUGIN_TOOLS, DYNAMIC_LAYOUTS], "{os:?}");
assert!(!action_ids(&spec).iter().any(|id| {
id.starts_with("toolwindow.")
&& *id != "toolwindow.hideAll"
+107
Переглянути файл
@@ -0,0 +1,107 @@
//! The `openidea-plugin` URL scheme: files of plugins' web views
//! (`<plugin id>/<revision>/<path>`, see `ide_host::plugins::web`).
//!
//! Pages run in a sandboxed frame (an opaque origin), so every response allows
//! any origin to read it (module scripts and fonts are fetched in CORS mode),
//! and is immutable: the revision in the URL changes with the installed
//! package. HTML gets a CSP of its own, in case a page navigates its frame to
//! one: still sandboxed, still no network.
use crate::state::AppState;
use tauri::http::{Request, Response, StatusCode, header};
use tauri::{Manager, Runtime, UriSchemeContext, UriSchemeResponder};
pub const SCHEME: &str = "openidea-plugin";
/// What a page loaded from the scheme may do: its own files, nothing else.
const HTML_CSP: &str = "sandbox allow-scripts; default-src 'none'; \
script-src openidea-plugin: http://openidea-plugin.localhost 'unsafe-inline' 'wasm-unsafe-eval'; \
style-src openidea-plugin: http://openidea-plugin.localhost 'unsafe-inline'; \
img-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
font-src openidea-plugin: http://openidea-plugin.localhost data:; \
media-src openidea-plugin: http://openidea-plugin.localhost data: blob:; \
connect-src openidea-plugin: http://openidea-plugin.localhost data: blob:";
/// `window.openidea` (one function expression taking the initial theme), the same
/// source the workbench inlines into rendered `html` bodies.
const BRIDGE: &str = include_str!("../../src/features/plugins/views/frame-bridge.js");
/// `html` with the bridge as its first script (the theme follows in a message).
fn with_bridge(html: &[u8]) -> Vec<u8> {
let bridge = format!("<script>({})(null);</script>", BRIDGE.trim().trim_end_matches(';'));
let text = String::from_utf8_lossy(html);
let lower = text.to_ascii_lowercase();
// After `<head …>` when there is one, else first.
let at = lower
.find("<head")
.and_then(|i| lower[i..].find('>').map(|j| i + j + 1))
.unwrap_or_else(|| lower.find("<html").and_then(|i| lower[i..].find('>').map(|j| i + j + 1)).unwrap_or(0));
let mut out = String::with_capacity(text.len() + bridge.len());
out.push_str(&text[..at]);
out.push_str(&bridge);
out.push_str(&text[at..]);
out.into_bytes()
}
pub fn handle<R: Runtime>(ctx: UriSchemeContext<'_, R>, request: Request<Vec<u8>>, responder: UriSchemeResponder) {
let app = ctx.app_handle().clone();
let path = request.uri().path().to_owned();
tauri::async_runtime::spawn_blocking(move || {
let response = match app.try_state::<AppState>() {
Some(state) => serve(&state.plugins, &path),
None => status(StatusCode::SERVICE_UNAVAILABLE),
};
responder.respond(response);
});
}
fn serve(plugins: &ide_host::plugins::PluginHost, path: &str) -> Response<Vec<u8>> {
let decoded = percent_encoding::percent_decode_str(path.trim_start_matches('/')).decode_utf8_lossy();
let Some((plugin, rest)) = decoded.split_once('/') else { return status(StatusCode::NOT_FOUND) };
match plugins.web_file(plugin, rest) {
Ok(file) => {
let mut response = Response::builder()
.header(header::CONTENT_TYPE, file.mime)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff");
let mut body = file.bytes;
if file.mime.starts_with("text/html") {
response = response.header(header::CONTENT_SECURITY_POLICY, HTML_CSP);
body = with_bridge(&body);
}
response.body(body).unwrap_or_else(|_| status(StatusCode::INTERNAL_SERVER_ERROR))
}
Err(e) => {
tracing::debug!("{SCHEME}: {path}: {}", e.message);
status(match e.kind {
ide_core::error::ErrorKind::Unavailable => StatusCode::FORBIDDEN,
ide_core::error::ErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
_ => StatusCode::NOT_FOUND,
})
}
}
}
fn status(code: StatusCode) -> Response<Vec<u8>> {
Response::builder()
.status(code)
.header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
.body(Vec::new())
.expect("a status response")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_bridge_is_the_first_script() {
let out = String::from_utf8(with_bridge(b"<!doctype html><html><HEAD lang=x><script>app()</script>")).unwrap();
let bridge = out.find("window.openidea").unwrap();
assert!(out.find("<HEAD lang=x>").unwrap() < bridge && bridge < out.find("app()").unwrap(), "{out}");
assert!(out.contains("})(null);</script>"), "{out}");
let bare = String::from_utf8(with_bridge(b"<p>x</p>")).unwrap();
assert!(bare.starts_with("<script>(") && bare.ends_with("<p>x</p>"));
}
}
+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 {
+2 -2
Переглянути файл
@@ -16,8 +16,8 @@ pub struct AppState {
pub host: Arc<Host>,
pub startup: Startup,
pub recent: RecentProjects,
/// WASM plugins (`ide-plugin`), shared by all windows.
pub plugins: Arc<ide_plugin::PluginHost>,
/// Plugins (`ide_host::plugins`), shared by all windows.
pub plugins: Arc<ide_host::plugins::PluginHost>,
/// The built-in web server (Open In › Browser, the editor's browser bar).
pub web: ide_web::WebServer,
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).