diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eca369c9..dfed0cc8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,8 +100,26 @@ jobs: cargo test --locked --manifest-path "$dir/Cargo.toml" echo "::endgroup::" done + - uses: pnpm/action-setup@v4 + with: { version: 11 } + - uses: actions/setup-node@v4 + with: { node-version: 22 } + # Plugin web pages (`web-src/`, their own pnpm projects): typecheck and test. + - name: Web pages typecheck and test + shell: bash + run: | + set -euo pipefail + for dir in plugins/*/web-src/ plugins/official/*/web-src/; do + [ -f "$dir/package.json" ] || continue + echo "::group::$dir" + pnpm --dir "$dir" install --frozen-lockfile --ignore-workspace + pnpm --dir "$dir" exec tsc --noEmit + pnpm --dir "$dir" test + echo "::endgroup::" + done # The host's tests run the checked-in components: they must be built from these sources. - - name: Checked-in components are up to date + # Web pages aren't checked in: this builds them and checks the result. + - name: Checked-in components are up to date, web pages build run: plugins/build.sh --check deny: diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 70f64132..0a0d7102 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -15,6 +15,8 @@ "pnpm-lock.yaml", "**/*.toml", "fixtures", + "plugins/*/web", + "plugins/official/*/web", "crates/**/tests/fixtures" ], "overrides": [ diff --git a/Cargo.lock b/Cargo.lock index 37a6cf58..974e0f90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6181,6 +6181,7 @@ dependencies = [ name = "openidea" version = "0.1.35" dependencies = [ + "base64 0.22.1", "blake3", "block2 0.6.2", "dirs 6.0.0", @@ -6227,6 +6228,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-web-kit 0.3.2", "parking_lot", + "percent-encoding", "security-framework", "serde", "serde_json", diff --git a/crates/ide-extension/src/capability.rs b/crates/ide-extension/src/capability.rs index f34a9dc9..b48a3874 100644 --- a/crates/ide-extension/src/capability.rs +++ b/crates/ide-extension/src/capability.rs @@ -28,6 +28,8 @@ pub enum Capability { Events, /// Keystrokes and editor input events. InputEvents, + /// The system clipboard, from the plugin's web views. + Clipboard, /// HTTP(S) to one host (`*.example.com` matches subdomains). Net(String), /// Run one external program. Full code execution: never fine-grained. @@ -52,7 +54,9 @@ impl Capability { pub fn risk(&self) -> Risk { match self { Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low, - Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents => Risk::Medium, + Self::EditorWrite | Self::VcsWrite | Self::Net(_) | Self::Secrets | Self::InputEvents | Self::Clipboard => { + Risk::Medium + } Self::Process(_) | Self::FsRead(_) => Risk::High, } } @@ -75,6 +79,7 @@ impl Capability { Self::Secrets => "Store secrets in the system keychain".into(), Self::Events => "Observe workbench events (files opened, saved, focus)".into(), Self::InputEvents => "Observe keystrokes and editor input".into(), + Self::Clipboard => "Read and write the clipboard from its web views".into(), Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"), Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"), Self::FsRead(path) => format!("Read files under {path}, outside the project"), @@ -115,6 +120,7 @@ impl fmt::Display for Capability { Self::Secrets => f.write_str("secrets"), Self::Events => f.write_str("events"), Self::InputEvents => f.write_str("events.input"), + Self::Clipboard => f.write_str("clipboard"), Self::Net(h) => write!(f, "net:{h}"), Self::Process(b) => write!(f, "process:{b}"), Self::FsRead(p) => write!(f, "fs.read:{p}"), @@ -169,6 +175,7 @@ impl FromStr for Capability { "secrets" => Self::Secrets, "events" => Self::Events, "events.input" => Self::InputEvents, + "clipboard" => Self::Clipboard, _ => return Err(format!("unknown capability {s:?}")), }) } @@ -241,6 +248,8 @@ mod tests { assert_eq!(Capability::ProjectRead.risk(), Risk::Low); assert_eq!(Capability::Net("x".into()).risk(), Risk::Medium); assert_eq!(Capability::InputEvents.risk(), Risk::Medium); + assert_eq!("clipboard".parse::().unwrap(), Capability::Clipboard); + assert_eq!(Capability::Clipboard.risk(), Risk::Medium); assert_eq!(Capability::Process("cargo".into()).risk(), Risk::High); assert!(Capability::Storage.auto_migrates() && !Capability::EditorWrite.auto_migrates()); } diff --git a/crates/ide-host/src/plugins/icons.rs b/crates/ide-host/src/plugins/icons.rs index 8df6a94b..729bf626 100644 --- a/crates/ide-host/src/plugins/icons.rs +++ b/crates/ide-host/src/plugins/icons.rs @@ -70,6 +70,16 @@ pub fn svg_prefix(theme: &str) -> String { format!("iconTheme/{theme}/svg/") } +/// An SVG icon of the package, size-checked and sanitised. +pub(super) fn read_icon(pkg: &Package, path: &std::path::Path) -> Result { + check_relative(path, "icon")?; + let svg = pkg.read_text(path)?; + if svg.len() > MAX_SVG { + return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); + } + sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display())) +} + pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result { let d: Decl = decode(POINT_ID, decl)?; validate_item_id(&d.id, "icon theme id")?; @@ -117,12 +127,7 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result MAX_SVG { - return Err(format!("{}: larger than {} KiB", path.display(), MAX_SVG >> 10)); - } - let clean = sanitize_svg(&svg).map_err(|e| format!("{}: {e}", path.display()))?; + let clean = read_icon(pkg, path)?; assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean)); files.push(path.clone()); } diff --git a/crates/ide-host/src/plugins/mod.rs b/crates/ide-host/src/plugins/mod.rs index 745be9ba..34c625df 100644 --- a/crates/ide-host/src/plugins/mod.rs +++ b/crates/ide-host/src/plugins/mod.rs @@ -28,6 +28,7 @@ mod theme; mod types; pub mod ui_points; pub mod views; +pub mod web; pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore}; pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix}; @@ -2057,8 +2058,10 @@ impl PluginHost { fn info(&self, p: &LoadedPlugin) -> PluginInfo { let m = p.manifest(); - let meta = p.meta.lock(); + // The ledger before a plugin's `meta`, as everywhere (`refresh_pins` holds the + // ledger and locks each plugin's): the other order deadlocks. let grant = self.ledger.lock().get(&p.subject()).cloned(); + let meta = p.meta.lock(); let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.confirmed && g.has(c)); let mut capabilities: Vec = m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect(); diff --git a/crates/ide-host/src/plugins/points.rs b/crates/ide-host/src/plugins/points.rs index 9874bd40..bdf23c66 100644 --- a/crates/ide-host/src/plugins/points.rs +++ b/crates/ide-host/src/plugins/points.rs @@ -65,5 +65,7 @@ pub fn register_builtin_points( registry.register(Arc::new(UiPoint::new(p::SETTINGS, ui.clone(), p::settings)))?; registry.register(Arc::new(UiPoint::new(p::VIEWS, ui.clone(), p::view)))?; registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?; + registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?; + registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?; Ok(()) } diff --git a/crates/ide-host/src/plugins/ui_points.rs b/crates/ide-host/src/plugins/ui_points.rs index 88d3c89c..abbf9fcd 100644 --- a/crates/ide-host/src/plugins/ui_points.rs +++ b/crates/ide-host/src/plugins/ui_points.rs @@ -32,6 +32,20 @@ //! title = "Table" //! patterns = ["*.csv", "*.tsv"] //! priority = "default" # or "option": only through Open With +//! layout = "tab" # or "split" / "preview": beside the text editor +//! web = "web/index.html" # a web page from the package instead of rendered content +//! +//! [[contributes.fileIcons]] +//! id = "drawing" +//! patterns = ["*.excalidraw"] +//! icon = "icons/drawing.svg" +//! iconDark = "icons/drawing_dark.svg" +//! +//! [[contributes.fileTemplates]] +//! id = "drawing" +//! name = "Excalidraw Drawing" +//! extension = "excalidraw" +//! path = "templates/drawing.excalidraw" //! ``` use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when}; @@ -46,6 +60,8 @@ pub const STATUS_BAR: &str = "statusBar"; pub const SETTINGS: &str = "settings"; pub const VIEWS: &str = "views"; pub const CUSTOM_EDITORS: &str = "customEditors"; +pub const FILE_ICONS: &str = "fileIcons"; +pub const FILE_TEMPLATES: &str = "fileTemplates"; const MENU_IDS: [&str; 4] = ["editor.context", "projectView.context", "editorTab.context", "tools"]; const MAX_TITLE: usize = 256; @@ -333,22 +349,36 @@ struct ViewDecl { icon: Option, #[serde(default = "right")] location: String, + /// A web page of the package (`web/index.html`) instead of content the code renders. + #[serde(default, skip_serializing_if = "Option::is_none")] + web: Option, +} + +/// The files of a `web` entry, or a check that the code renders the view. +fn web_or_code(pkg: &Package, web: Option<&str>, what: &str) -> Result, String> { + match web { + Some(entry) => super::web::web_files(pkg, entry, what), + None if pkg.manifest.wasm.is_none() => { + Err(format!("{what}: give a web page (`web`) or the code that renders it (`wasm`)")) + } + None => Ok(Vec::new()), + } } pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result { let d: ViewDecl = decode(VIEWS, decl)?; validate_item_id(&d.id, "view id")?; title(VIEWS, &d.title)?; - if pkg.manifest.wasm.is_none() { - return Err(format!("view {:?}: views are rendered by the plugin's code (`wasm`)", d.id)); - } + let files = web_or_code(pkg, d.web.as_deref(), &format!("view {:?}", d.id))?; if !["left", "right", "bottom"].contains(&d.location.as_str()) { return Err(format!("view {:?}: `location` is left, right or bottom", d.id)); } if d.icon.as_ref().is_some_and(|i| i.len() > 64) { return Err(format!("view {:?}: icon names are up to 64 bytes", d.id)); } - Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?)) + let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?); + out.files = files; + Ok(out) } #[derive(Debug, Deserialize, Serialize)] @@ -359,31 +389,125 @@ struct CustomEditorDecl { patterns: Vec, #[serde(default = "default_priority")] priority: String, + /// `tab`: the file opens in this editor's own tab. `split` / `preview`: + /// the file opens in the text editor with this editor beside it (Editor / + /// Editor and Preview / Preview); the value is the layout files start in. + #[serde(default = "default_layout")] + layout: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + web: Option, } fn default_priority() -> String { "default".into() } +fn default_layout() -> String { + "tab".into() +} + +/// File name globs (`*.csv`, `{a,b}.json`): no directories. +fn file_patterns(patterns: &[String], what: &str) -> Result<(), String> { + if patterns.is_empty() || patterns.len() > MAX_PATTERNS { + return Err(format!("{what}: 1–{MAX_PATTERNS} file name `patterns`")); + } + for p in patterns { + if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() { + return Err(format!("{what}: invalid file name pattern {p:?}")); + } + } + Ok(()) +} + pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result { let d: CustomEditorDecl = decode(CUSTOM_EDITORS, decl)?; validate_item_id(&d.id, "custom editor id")?; title(CUSTOM_EDITORS, &d.title)?; - if pkg.manifest.wasm.is_none() { - return Err(format!("custom editor {:?}: needs the plugin's code (`wasm`)", d.id)); - } - if d.patterns.is_empty() || d.patterns.len() > MAX_PATTERNS { - return Err(format!("custom editor {:?}: 1–{MAX_PATTERNS} file name `patterns`", d.id)); - } - for p in &d.patterns { - if p.is_empty() || p.len() > 128 || p.contains(['/', '\\']) || globset::Glob::new(p).is_err() { - return Err(format!("custom editor {:?}: invalid file name pattern {p:?}", d.id)); - } - } + let what = format!("custom editor {:?}", d.id); + let files = web_or_code(pkg, d.web.as_deref(), &what)?; + file_patterns(&d.patterns, &what)?; if d.priority != "default" && d.priority != "option" { - return Err(format!("custom editor {:?}: `priority` is default or option", d.id)); + return Err(format!("{what}: `priority` is default or option")); } - Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?)) + if !["tab", "split", "preview"].contains(&d.layout.as_str()) { + return Err(format!("{what}: `layout` is tab, split or preview")); + } + let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?); + out.files = files; + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileIconDecl { + id: String, + patterns: Vec, + icon: PathBuf, + #[serde(default)] + icon_dark: Option, +} + +/// Asset id of a file icon's SVG (`dark`: its dark-theme variant). +pub fn file_icon_asset(id: &str, dark: bool) -> String { + format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" }) +} + +/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`, +/// whatever icon theme is in use (the user's own file type icons still win). +pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result { + let d: FileIconDecl = decode(FILE_ICONS, decl)?; + validate_item_id(&d.id, "file icon id")?; + let what = format!("file icon {:?}", d.id); + file_patterns(&d.patterns, &what)?; + let mut files = Vec::new(); + let mut assets = Vec::new(); + for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] { + let Some(path) = path else { continue }; + let svg = super::icons::read_icon(pkg, path)?; + assets.push((file_icon_asset(&d.id, dark), svg)); + files.push(path.clone()); + } + let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() }); + let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json); + out.files = files; + out.assets = assets; + Ok(out) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct FileTemplateDecl { + id: String, + name: String, + /// Without the dot. + extension: String, + /// The new file's content (`$NAME$`, `$END$` and the other template variables work). + path: PathBuf, +} + +const MAX_TEMPLATE: usize = 256 << 10; + +/// `[[contributes.fileTemplates]]`: an entry of the New menu. +pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result { + let d: FileTemplateDecl = decode(FILE_TEMPLATES, decl)?; + validate_item_id(&d.id, "file template id")?; + title(FILE_TEMPLATES, &d.name)?; + let what = format!("file template {:?}", d.id); + let ext = d.extension.trim_start_matches('.'); + if ext.is_empty() + || ext.len() > 32 + || !ext.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) + { + return Err(format!("{what}: `extension` is a file extension like \"md\" ({:?})", d.extension)); + } + let body = pkg.read_text(&d.path)?; + if body.len() > MAX_TEMPLATE { + return Err(format!("{what}: {} is larger than {} KiB", d.path.display(), MAX_TEMPLATE >> 10)); + } + let json = serde_json::json!({ "id": d.id, "name": d.name, "extension": ext, "body": body }); + let mut out = UiDecl::new(d.id.clone(), d.name.clone(), json); + out.files = vec![d.path.clone()]; + Ok(out) } #[cfg(test)] diff --git a/crates/ide-host/src/plugins/web.rs b/crates/ide-host/src/plugins/web.rs new file mode 100644 index 00000000..11a109f8 --- /dev/null +++ b/crates/ide-host/src/plugins/web.rs @@ -0,0 +1,245 @@ +//! 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, 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 { + /// `//` 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 file of a web view. +pub struct WebFile { + pub bytes: Vec, + pub mime: &'static str, +} + +impl PluginHost { + /// The web roots `plugin` declares (entry directories, package-relative, `/`-terminated). + fn web_dirs(&self, plugin: &str) -> Vec { + [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 { + 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 `/` + /// 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 { + 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"), "

hi

").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 = 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"); + } +} diff --git a/crates/ide-host/tests/plugins.rs b/crates/ide-host/tests/plugins.rs index 0dcaf7b4..6ae5e832 100644 --- a/crates/ide-host/tests/plugins.rs +++ b/crates/ide-host/tests/plugins.rs @@ -1903,3 +1903,216 @@ fn official_kubernetes_browses_the_cluster_and_opens_logs() { assert_eq!(program.0, bin.join("kubectl").canonicalize().unwrap()); assert_eq!(args, ["logs", "-f", "--tail=200", "-n", "default", "web-1"]); } + +/// A plugin with web views and no code: its pages and their files are +/// installed with it and served only from the declared directories, while +/// it is enabled and only for the installed version; file icons and +/// templates are projected too. +#[test] +fn web_views_serve_only_their_own_files() { + let f = fixture(Limits::default()); + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("web-demo"); + for (path, text) in [ + ("web/index.html", "draw"), + ("web/assets/app.js", "console.log(1)"), + ("panel/index.html", "

panel

"), + ( + "icons/draw.svg", + r##""##, + ), + ("templates/new.draw", "{\"type\": \"drawing\"}\n"), + ("secret.txt", "not served"), + ] { + std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap(); + std::fs::write(pkg.join(path), text).unwrap(); + } + std::fs::write( + pkg.join("plugin.toml"), + r#"id = "web-demo" +name = "Web Demo" +version = "1.0.0" +engines.openidea = "^1.0" + +[capabilities] +optional = ["clipboard"] + +[[contributes.customEditors]] +id = "draw" +title = "Drawing" +patterns = ["*.draw"] +layout = "preview" +web = "web/index.html" + +[[contributes.views]] +id = "panel" +title = "Panel" +web = "panel/index.html" + +[[contributes.fileIcons]] +id = "draw" +patterns = ["*.draw"] +icon = "icons/draw.svg" + +[[contributes.fileTemplates]] +id = "draw" +name = "Drawing" +extension = "draw" +path = "templates/new.draw" +"#, + ) + .unwrap(); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + assert!(info.capabilities.iter().any(|c| c.id == "clipboard" && c.granted), "{:?}", info.capabilities); + // The pages' directories are installed with the plugin; files nothing declares are not. + let installed = f.installed("web-demo"); + assert!(installed.join("web/assets/app.js").is_file() && installed.join("panel/index.html").is_file()); + assert!(!installed.join("secret.txt").exists()); + + let page = f.host.web_page("web-demo", "draw").unwrap(); + let (rev, entry) = page.path.strip_prefix("web-demo/").unwrap().split_once('/').unwrap(); + assert_eq!(entry, "web/index.html"); + let file = |path: &str| f.host.web_file("web-demo", path); + let js = file(&format!("{rev}/web/assets/app.js")).unwrap(); + assert_eq!((js.bytes.as_slice(), js.mime), (b"console.log(1)".as_slice(), "text/javascript; charset=utf-8")); + assert!(file(&format!("{rev}/panel/index.html")).is_ok()); + assert_eq!(f.host.web_page("web-demo", "panel").unwrap().path, format!("web-demo/{rev}/panel/index.html")); + for denied in [ + format!("{rev}/secret.txt"), + format!("{rev}/plugin.toml"), + format!("{rev}/web/../secret.txt"), + format!("{rev}/icons/draw.svg"), + "stale-revision/web/index.html".to_owned(), + ] { + assert!(file(&denied).is_err(), "{denied} was served"); + } + assert!(f.host.web_page("web-demo", "nope").is_err()); + + // File icons and templates. + let icon = f.host.assets("web-demo", "fileIcon/draw/").unwrap(); + assert_eq!(icon.len(), 1); + assert!(icon[0].0 == "fileIcon/draw/light" && icon[0].1.contains(" = (0..4) + .map(|i| { + let (host, done) = (host.clone(), done.clone()); + let root = f._tmp.path().join(format!("p{i}")); + std::thread::spawn(move || { + while !done.load(std::sync::atomic::Ordering::Relaxed) { + if i % 2 == 0 { + host.list(None); + } else { + host.restrict_project(&root); + } + } + }) + }) + .collect(); + std::thread::sleep(Duration::from_millis(500)); + done.store(true, std::sync::atomic::Ordering::Relaxed); + let started = Instant::now(); + for w in workers { + while !w.is_finished() { + assert!(started.elapsed() < Duration::from_secs(10), "deadlocked"); + std::thread::sleep(Duration::from_millis(10)); + } + w.join().unwrap(); + } +} + +/// Excalidraw: a web custom editor beside the text editor, with its fonts, +/// file icon, template and language — no code. +#[test] +fn official_excalidraw_installs_and_serves_its_page() { + // Its page is built by plugins/build.sh, not checked in: the package's own + // files, with a stand-in for the build (what build.sh checks it produces). + let tmp = tempfile::tempdir().unwrap(); + let pkg = tmp.path().join("excalidraw"); + let src = official("excalidraw"); + for entry in std::fs::read_dir(&src).unwrap().flatten() { + let name = entry.file_name(); + if ["web", "web-src", "web.sources"].iter().any(|n| name == *n) { + continue; + } + let to = pkg.join(&name); + if entry.path().is_dir() { + copy_dir(&entry.path(), &to); + } else { + std::fs::create_dir_all(&pkg).unwrap(); + std::fs::copy(entry.path(), to).unwrap(); + } + } + std::fs::create_dir_all(pkg.join("web/assets")).unwrap(); + std::fs::create_dir_all(pkg.join("web/fonts/Virgil")).unwrap(); + std::fs::write( + pkg.join("web/index.html"), + r#""#, + ) + .unwrap(); + std::fs::write(pkg.join("web/assets/index.js"), "export {};").unwrap(); + std::fs::write(pkg.join("web/fonts/Virgil/Virgil-Regular.woff2"), b"wOF2").unwrap(); + + let f = fixture(Limits::default()); + let info = f.install(&pkg); + assert_eq!(info.status, PluginStatus::Static, "{:?}", info.error); + let page = f.host.web_page("openidea.excalidraw", "canvas").unwrap(); + let (rev, entry) = page.path.strip_prefix("openidea.excalidraw/").unwrap().split_once('/').unwrap(); + assert_eq!(entry, "web/index.html"); + let html = f.host.web_file("openidea.excalidraw", &format!("{rev}/{entry}")).unwrap(); + let html = String::from_utf8(html.bytes).unwrap(); + // Relative URLs only: the page is served from the package. + let script = html.split("src=\"").nth(1).unwrap().split('"').next().unwrap(); + assert!(script.starts_with("./assets/"), "{script}"); + assert!(f.host.web_file("openidea.excalidraw", &format!("{rev}/web/{}", &script[2..])).is_ok()); + let font = f.host.web_file("openidea.excalidraw", &format!("{rev}/web/fonts/Virgil/Virgil-Regular.woff2")).unwrap(); + assert_eq!(font.mime, "font/woff2"); + assert_eq!(f.host.ui_items("customEditors")[0].decl["layout"], "preview"); + assert!(f.host.languages().iter().any(|l| l.id.as_str() == "excalidraw")); + assert_eq!(f.host.assets("openidea.excalidraw", "fileIcon/excalidraw/").unwrap().len(), 2); +} diff --git a/plugins/README.md b/plugins/README.md index fc425882..5138c268 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -98,21 +98,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK. ### Contribution points -| Point | Declaration | Code needed | -| --------------- | -------------------------------------------------------------------------------------------------- | ----------- | -| `commands` | `id`, `title`, `category?`, `when?` | to run them | -| `themes` | `path` (theme JSON), `editor-scheme?` | no | -| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | -| `languages` | `path` (`languages/.toml` format, see §6) | no | -| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | -| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | -| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | -| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no | -| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | -| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | -| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | -| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`) | yes | -| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`) | yes | +| Point | Declaration | Code needed | +| --------------- | -------------------------------------------------------------------------------------------------- | ------------- | +| `commands` | `id`, `title`, `category?`, `when?` | to run them | +| `themes` | `path` (theme JSON), `editor-scheme?` | no | +| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no | +| `languages` | `path` (`languages/.toml` format, see §6) | no | +| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no | +| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes | +| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them | +| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no | +| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no | +| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no | +| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no | +| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` | +| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` | +| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no | +| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no | Declared contributions are live as soon as the plugin is enabled — commands show in Find Action, menus and the status bar, shortcuts work, themes and icon @@ -162,6 +164,7 @@ machine. Users can revoke optional capabilities in Settings › Plugins. | `process:` | high | `Project::run_process`, language servers, debug adapters, runs | | `net:` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) | | `secrets` | medium | `{{secret:}}` in request headers, `host::secret_is_set` | +| `clipboard` | medium | the clipboard API in the plugin's web views (keyboard copy / paste work without it) | `process:` capabilities are also derived from what the contributions start: a language's `language-servers` and `debugger` commands and a run detector's @@ -384,6 +387,12 @@ plugin's editor, or through _Open With_ for `option`. For a custom editor Undo / Redo are one history with it, and Save, the dirty mark and the close prompts are the document's. _Open as Text_ opens the text editor. +`layout` puts a custom editor beside the text editor instead of in a tab of +its own: with `"split"` or `"preview"` the file opens in the text editor, +with your editor next to it (the layout files start in) and the _Editor Only / +Editor and … / … Only_ switch in the tab strip, as for Markdown. `"tab"` +(the default) is the editor's own tab. + `Html` is the escape hatch for content the declarative bodies can't show. It runs in a sandboxed frame — an opaque origin under a CSP that allows only its inline scripts and styles and `data:` images, no network, no IDE API — and @@ -407,6 +416,49 @@ Scripts get the same as `openidea.theme` (`{ dark, vars }`) and `openidea.onThemeChange(fn)`, for drawing on a canvas, say. Variables are only ever added. +#### Web views + +A view or custom editor can instead be a web page of the package, for UIs the +declarative bodies can't express (a drawing canvas, a diagram editor) — no +code needed: + +```toml +[[contributes.customEditors]] +id = "canvas" +title = "Drawing" +patterns = ["*.excalidraw"] +layout = "preview" +web = "web/index.html" # the page; its directory is what it can load +``` + +The page's directory (never the package root) is part of the declaration: +every file under it is checked (at most 4096 files and 256 MiB, no links), +installed and signed with the package. The IDE loads the page from its own +`openidea-plugin` URL scheme into an `