Файли
openidea/crates/ide-host/src/plugins/web.rs
T
l0ng-ai 2bf412ff0d feat(plugins): let plugin code open streaming log tabs (#2082)
views.open-log-tab(key, title, process-log) (SDK host::open_log_tab) opens the read-only log tab a web view opens with open-log-tab, from a command or event of a window, reusing the Services pane's process-log.

The host checks the process grant and limits when called and remembers what each key runs in that window; the tab starts it by key (plugin_log_tab_spawn) through the web-view PTY checks: pinned program, trusted project, session cap, lifecycle and grant revocation. Remote windows report that these tabs aren't available there yet.
2026-11-08 10:01:35 +08:00

460 рядки
19 KiB
Rust

//! Web views: a view or custom editor whose UI is a web page shipped in the
//! package (`web = "web/index.html"`) instead of content the plugin's code
//! renders.
//!
//! The entry's directory is the page's root. Every file under it is part of
//! the declaration: it is checked when the package is prepared (count, size,
//! no links leaving the package), copied on install and covered by the
//! package digest and signature. The client loads the page in a sandboxed
//! frame (an opaque origin with no network and no IDE API) and fetches its
//! files through [`PluginHost::web_file`] (the desktop's `openidea-plugin`
//! URL scheme); the page talks to the IDE only through the frame bridge.
use super::PluginHost;
use ide_core::{IdeError, IdeResult};
use ide_extension::{Package, check_relative};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::{Component, Path, PathBuf};
/// Most files under one web root.
const MAX_FILES: usize = 4096;
/// Most bytes under one web root.
const MAX_BYTES: u64 = 256 << 20;
/// Deepest directory nesting under a web root.
const MAX_DEPTH: usize = 16;
/// Checks a declaration's `web` entry: an `.html` file inside a directory of
/// the package (the directory is what the page can load, so it can't be the
/// package root), and everything under that directory. Returns those files,
/// package-relative.
pub(super) fn web_files(pkg: &Package, entry: &str, what: &str) -> Result<Vec<PathBuf>, String> {
let path = Path::new(entry);
check_relative(path, "web")?;
if entry.contains('\\') || !entry.to_ascii_lowercase().ends_with(".html") {
return Err(format!("{what}: `web` names an .html file, `/`-separated ({entry:?})"));
}
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).ok_or_else(|| {
format!("{what}: put the page in a directory of its own (`web/index.html`): the directory is what it can load")
})?;
let real_entry = pkg.resolve(path)?;
if !real_entry.is_file() {
return Err(format!("{what}: {entry} is not a file"));
}
let root = pkg.resolve(dir)?;
let mut files = Vec::new();
let mut total = 0u64;
let mut stack = vec![(root.clone(), dir.to_path_buf(), 0usize)];
while let Some((real, rel, depth)) = stack.pop() {
let mut entries: Vec<_> =
std::fs::read_dir(&real).map_err(|e| format!("{}: {e}", rel.display()))?.filter_map(Result::ok).collect();
entries.sort_by_key(|e| e.file_name());
for e in entries {
let name = e.file_name();
let rel = rel.join(&name);
// Links could point anywhere: only what is really in the package counts.
let ty = e.file_type().map_err(|err| format!("{}: {err}", rel.display()))?;
if ty.is_symlink() {
return Err(format!("{what}: {} is a link; web files must be real files", rel.display()));
}
if ty.is_dir() {
if depth + 1 > MAX_DEPTH {
return Err(format!("{what}: {} is nested deeper than {MAX_DEPTH} directories", rel.display()));
}
stack.push((e.path(), rel, depth + 1));
continue;
}
if name.to_string_lossy() == ".DS_Store" {
continue;
}
let len = e.metadata().map_err(|err| format!("{}: {err}", rel.display()))?.len();
if len > ide_extension::MAX_PACKAGE_FILE {
return Err(format!(
"{what}: {} is larger than {} MiB",
rel.display(),
ide_extension::MAX_PACKAGE_FILE >> 20
));
}
total += len;
files.push(rel);
if files.len() > MAX_FILES || total > MAX_BYTES {
return Err(format!(
"{what}: {} holds more than {MAX_FILES} files or {} MiB",
dir.display(),
MAX_BYTES >> 20
));
}
}
}
files.sort();
Ok(files)
}
/// The page of a web view, for the client to load in its frame.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPage {
/// `<plugin id>/<revision>/<entry>` under the `openidea-plugin` scheme. The
/// revision changes with the installed package, so a cached file is never
/// stale; relative URLs in the page resolve to the package's files.
pub path: String,
}
/// A web frame's API 1.3 PTY request. The frame never supplies an executable path or cwd.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebPtyCommand {
pub program: String,
pub args: Vec<String>,
pub env: std::collections::BTreeMap<String, String>,
pub cols: u16,
pub rows: u16,
}
pub(super) struct WebPtySession {
id: ide_core::TerminalId,
host: std::sync::Weak<crate::Host>,
window: String,
}
/// A file of a web view.
pub struct WebFile {
pub bytes: Vec<u8>,
pub mime: &'static str,
}
impl PluginHost {
/// Uses the normal PTY backend, without user shell fallback or startup commands.
#[allow(clippy::too_many_arguments)]
pub fn spawn_web_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.web_pty_options(plugin_id, view, window, command)
})
}
/// Starts the program of the log tab `key` that the plugin's code opened
/// in `window` (`views.open-log-tab`), with the toggles in `on`: the UI
/// names the tab, never what runs.
#[allow(clippy::too_many_arguments)]
pub fn spawn_log_tab_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
output: crate::ops::Emit<String>,
) -> IdeResult<ide_terminal::TerminalInfo> {
self.spawn_plugin_pty(host, plugin_id, window, output, || {
self.log_tab_pty_options(plugin_id, key, window, on, (cols, rows))
})
}
/// The PTY of a plugin, counted against its sessions and ended with its
/// window, project or permission; `options` authorizes it.
fn spawn_plugin_pty(
&self,
host: &std::sync::Arc<crate::Host>,
plugin_id: &str,
window: &str,
output: crate::ops::Emit<String>,
options: impl FnOnce() -> IdeResult<ide_terminal::TerminalOptions>,
) -> IdeResult<ide_terminal::TerminalInfo> {
let _guard = self.lifecycle.lock();
let mut sessions = self.web_ptys.lock();
if self.shared.client_closed(window) {
return Err(IdeError::cancelled());
}
let options = options()?;
let entries = sessions.entry(plugin_id.to_owned()).or_default();
entries.retain(|s| s.host.upgrade().is_some_and(|h| h.terminals.contains(s.id)));
if entries.len() >= 8 {
return Err(IdeError::invalid("a plugin may own at most 8 PTYs"));
}
let cwd = options.cwd.0.clone();
let info = host.terminals.spawn_program(
options,
host.owner_window(window).to_owned(),
host.window_sink(window),
Box::new(move |data| output(data)),
)?;
if !self
.shared
.workspace()
.is_some_and(|workspace| workspace.project_of(window).as_ref() == Some(&cwd) && workspace.trusted(&cwd))
{
let _ = host.terminals.kill(info.id);
return Err(IdeError::cancelled());
}
entries.push(WebPtySession { id: info.id, host: std::sync::Arc::downgrade(host), window: window.to_owned() });
Ok(info)
}
pub(super) fn forget_web_ptys(&self, window: &str) {
self.shared.forget_log_tabs(None, Some(window));
for sessions in self.web_ptys.lock().values_mut() {
sessions.retain(|session| {
if session.window != window {
return true;
}
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
false
});
}
}
pub(super) fn stop_web_ptys(&self, plugin: &str) {
self.shared.forget_log_tabs(Some(plugin), None);
if let Some(sessions) = self.web_ptys.lock().remove(plugin) {
for session in sessions {
if let Some(host) = session.host.upgrade() {
let _ = host.terminals.kill(session.id);
}
}
}
}
/// Authorizes a PTY immediately before spawning through the existing terminal backend.
pub fn web_pty_options(
&self,
plugin_id: &str,
view: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
self.web_page(plugin_id, view)?;
self.get(plugin_id)?.pkg.manifest.needs_api("web view PTYs", 3).map_err(IdeError::invalid)?;
self.pty_options(plugin_id, window, command)
}
/// The options of the log tab `key`'s program, with the toggles in `on`.
pub fn log_tab_pty_options(
&self,
plugin_id: &str,
key: &str,
window: &str,
on: &[String],
(cols, rows): (u16, u16),
) -> IdeResult<ide_terminal::TerminalOptions> {
let log = self.shared.log_tab(plugin_id, window, key).ok_or_else(|| {
IdeError::not_found(format!("plugin {plugin_id:?} opened no log tab {key:?} in this window"))
})?;
let toggled = log.toggles.into_iter().filter(|t| on.contains(&t.id)).flat_map(|t| t.args);
let args = log.args.into_iter().chain(toggled).collect();
let command = WebPtyCommand { program: log.program, args, env: Default::default(), cols, rows };
self.pty_options(plugin_id, window, command)
}
/// Checks `command` against the plugin's current grant, the limits and
/// the window's trusted project.
fn pty_options(
&self,
plugin_id: &str,
window: &str,
command: WebPtyCommand,
) -> IdeResult<ide_terminal::TerminalOptions> {
let plugin = self.get(plugin_id)?;
let cap = ide_extension::Capability::Process(command.program.clone());
if !plugin.meta.lock().granted.contains(&cap) {
return Err(IdeError::new(
ide_core::error::ErrorKind::PermissionDenied,
format!("{cap} capability not granted"),
));
}
if command.program.is_empty()
|| command.program.len() > 256
|| command.program.trim() != command.program
|| command.program.contains(['\0', '\r', '\n'])
|| command.args.len() > 256
|| command.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|| !(2..=500).contains(&command.cols)
|| !(1..=500).contains(&command.rows)
{
return Err(IdeError::invalid("invalid PTY program, arguments or dimensions"));
}
if command.env.len() > 64
|| command.env.iter().any(|(k, v)| {
k.is_empty()
|| k.len() > 256
|| k.contains(['=', '\0'])
|| v.len() > 4096
|| v.contains('\0')
|| std::env::vars_os().any(|(inherited, _)| {
if cfg!(windows) {
inherited.to_string_lossy().eq_ignore_ascii_case(k)
} else {
inherited == k.as_str()
}
})
})
{
return Err(IdeError::invalid("PTY environment may only add variables, within the size limits"));
}
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
let cwd = workspace.project_of(window).ok_or_else(|| IdeError::invalid("no project in this window"))?;
if !cwd.is_absolute() || !cwd.is_dir() {
return Err(IdeError::invalid("the project directory no longer exists"));
}
if !workspace.trusted(&cwd) {
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
}
let program = self.program_for(plugin_id, &command.program)?;
Ok(ide_terminal::TerminalOptions {
cwd: cwd.into(),
shell: Some(program.to_string_lossy().into_owned()),
args: command.args,
env: command.env,
cols: command.cols,
rows: command.rows,
shell_integration: false,
})
}
/// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated).
fn web_dirs(&self, plugin: &str) -> Vec<String> {
[super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin)))
.filter_map(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.filter_map(|entry| entry.rsplit_once('/').map(|(dir, _)| format!("{dir}/")))
.collect()
}
/// Changes whenever the installed package does (cache key of its web files).
fn web_revision(&self, plugin: &super::LoadedPlugin) -> String {
let digest = plugin.pkg.digest.as_deref().unwrap_or_default();
let short: String = digest.chars().filter(char::is_ascii_alphanumeric).take(16).collect();
if short.is_empty() { format!("v{}", plugin.pkg.manifest.version) } else { short }
}
/// The page of `plugin`'s view or custom editor `view`.
pub fn web_page(&self, plugin_id: &str, view: &str) -> IdeResult<WebPage> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let entry = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.flat_map(|point| self.ui.items(point, Some(plugin_id)))
.find(|item| item.key == view)
.and_then(|item| item.decl.get("web").and_then(|w| w.as_str()).map(str::to_owned))
.ok_or_else(|| IdeError::not_found(format!("plugin {plugin_id:?} has no web view {view:?}")))?;
Ok(WebPage { path: format!("{plugin_id}/{}/{entry}", self.web_revision(&plugin)) })
}
/// A file of one of `plugin`'s web views: `path` is `<revision>/<package path>`
/// as [`WebPage::path`] makes them. Only files under a declared web root
/// of a live plugin are served; the revision must be the installed one.
pub fn web_file(&self, plugin_id: &str, path: &str) -> IdeResult<WebFile> {
let plugin = self.get(plugin_id)?;
if !plugin.status().is_live() {
return Err(IdeError::unavailable(format!("plugin {plugin_id:?} is not enabled")));
}
let (revision, rel) = path.split_once('/').ok_or_else(|| IdeError::not_found(path.to_owned()))?;
if revision != self.web_revision(&plugin) {
return Err(IdeError::not_found(format!("{path}: not the installed version")));
}
let rel_path = Path::new(rel);
if !rel_path.components().all(|c| matches!(c, Component::Normal(_))) {
return Err(IdeError::invalid(format!("{rel}: not a plain relative path")));
}
if !self.web_dirs(plugin_id).iter().any(|dir| rel.starts_with(dir.as_str())) {
return Err(IdeError::not_found(format!("{rel}: not part of a web view")));
}
let bytes = plugin.pkg.read_bytes(rel_path).map_err(IdeError::not_found)?;
Ok(WebFile { bytes, mime: mime(rel) })
}
}
/// Content type by extension; unknown files are opaque bytes (never sniffed as script).
pub fn mime(path: &str) -> &'static str {
let ext = path.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" | "cjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json",
"wasm" => "application/wasm",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"ttf" => "font/ttf",
"otf" => "font/otf",
"txt" | "md" => "text/plain; charset=utf-8",
"mp3" => "audio/mpeg",
"mp4" => "video/mp4",
"webm" => "video/webm",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_extension::Manifest;
fn pkg(dir: &Path) -> Package {
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
Package::new(Manifest::parse(src).unwrap(), dir.to_path_buf())
}
#[test]
fn a_web_root_is_its_directory_and_nothing_else() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web/assets/fonts")).unwrap();
std::fs::write(d.join("web/index.html"), "<p>hi</p>").unwrap();
std::fs::write(d.join("web/assets/app.js"), "1").unwrap();
std::fs::write(d.join("web/assets/fonts/a.woff2"), "f").unwrap();
std::fs::write(d.join("web/.DS_Store"), "x").unwrap();
std::fs::write(d.join("secret.txt"), "no").unwrap();
let files = web_files(&pkg(d), "web/index.html", "v").unwrap();
let files: Vec<String> = files.iter().map(|f| f.to_string_lossy().replace('\\', "/")).collect();
assert_eq!(files, ["web/assets/app.js", "web/assets/fonts/a.woff2", "web/index.html"]);
std::fs::write(d.join("index.html"), "x").unwrap();
let err = |entry: &str| web_files(&pkg(d), entry, "v").err().unwrap_or_default();
assert!(err("index.html").contains("directory of its own"), "{}", err("index.html"));
assert!(err("web/app.js").contains(".html"));
assert!(err("../web/index.html").contains("relative"));
assert!(err("web/missing.html").contains("no such file"));
}
#[cfg(unix)]
#[test]
fn links_out_of_a_web_root_are_refused() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
std::fs::create_dir_all(d.join("web")).unwrap();
std::fs::write(d.join("web/index.html"), "x").unwrap();
std::os::unix::fs::symlink("/etc/hosts", d.join("web/hosts")).unwrap();
assert!(web_files(&pkg(d), "web/index.html", "v").unwrap_err().contains("is a link"));
}
#[test]
fn content_types_never_make_unknown_files_scripts() {
assert_eq!(mime("a/b.JS"), "text/javascript; charset=utf-8");
assert_eq!(mime("x.woff2"), "font/woff2");
assert_eq!(mime("x.wasm"), "application/wasm");
assert_eq!(mime("x.bin"), "application/octet-stream");
assert_eq!(mime("noext"), "application/octet-stream");
}
}