| //! Who a plugin is, what it may do, and where its data lives. | |
| //! | |
| //! A host that runs one application from a directory needs none of this: the | |
| //! command line names the directory, the grant is decided by the act of typing | |
| //! the command, and storage is keyed by the path. A host that runs *several* | |
| //! applications cannot do any of that, because the three questions become | |
| //! per plugin — identity, permission, storage — and all three have to be | |
| //! answerable **before** the plugin's code runs. | |
| //! | |
| //! That is the whole reason a manifest exists, and it is why the manifest has | |
| //! seven recognized fields: `id`, `name`, `version`, `shell-version`, `entry`, | |
| //! `dependencies`, and `capabilities`. `version`, `shell-version`, | |
| //! `dependencies`, and `capabilities` are | |
| //! optional. Commands, panels, keybindings, settings and themes are | |
| //! registered from script instead of being declared here a second time — | |
| //! *capabilities are permission, contributions are behavior*. A permission has | |
| //! to be shown to a user and approved before any code runs, so it belongs in | |
| //! data; a contribution is code, so it belongs in code. Declaring contributions | |
| //! in both places would create a class of bug (manifest and script disagreeing) | |
| //! while producing no information the script did not already carry. | |
| //! | |
| //! Two consequences run through this module: | |
| //! | |
| //! - **Discovery executes nothing.** [`PluginManager::discover`] reads | |
| //! manifests and stops. A host with thirty installed plugins lists thirty | |
| //! names, versions and permission sets without starting thirty programs. | |
| //! Only [`PluginManager::load`] evaluates script. | |
| //! - **Compatibility is checked before execution when declared.** | |
| //! `shell-version` names the oldest compatible gpui-shell release. Omitting | |
| //! it accepts the current runtime; an explicit incompatible version is | |
| //! rejected before the entry module can run. | |
| use std::{ | |
| collections::BTreeMap, | |
| path::{Path, PathBuf}, | |
| rc::Rc, | |
| }; | |
| use anyhow::{Context as _, Result, bail}; | |
| use gpui::{App, AppContext as _, Context, Entity, IntoElement, Render, Window}; | |
| use schemars::JsonSchema; | |
| use semver::Version; | |
| use serde::Deserialize; | |
| use crate::{ | |
| capability::{Capabilities, ExecuteGrant, HttpRequestGrant}, | |
| engine::ShellRuntime, | |
| policy::Policy, | |
| root::ShellRoot, | |
| runtime::failure_surface, | |
| scope::{self, ScopePhase}, | |
| view::ScriptView, | |
| }; | |
| /// The gpui-shell release that parses and executes this manifest. | |
| pub const SHELL_VERSION: &str = env!("CARGO_PKG_VERSION"); | |
| /// The file a plugin directory is recognized by. | |
| /// | |
| /// §18.1 shows the manifest's content but never names the file. | |
| /// `gpui-shell.json` makes the owning runtime explicit. | |
| pub const MANIFEST_FILE: &str = "gpui-shell.json"; | |
| /// The JSON Schema for a manifest, for editor validation. | |
| /// | |
| /// §18.1 keeps the schema worth generating but small enough to read: six | |
| /// fields, one nested permission object. `crates/component/src/theme/schema.rs` is the | |
| /// precedent for generating rather than hand-writing it — the schema and the | |
| /// parser then cannot disagree, because both come from the same type. | |
| pub fn manifest_schema() -> serde_json::Value { | |
| schemars::schema_for!(ManifestFile).to_value() | |
| } | |
| /// A plugin's identity and the permissions it asks for. | |
| /// | |
| /// Cloneable and inert: holding one runs nothing, which is what lets a host | |
| /// list, sort and display installed plugins — or show a permission sheet — with | |
| /// no plugin code loaded. | |
| #[derive(Clone, Debug, PartialEq)] | |
| pub struct PluginManifest { | |
| id: String, | |
| name: String, | |
| version: String, | |
| shell_version: String, | |
| entry: String, | |
| dependencies: BTreeMap<String, GitDependency>, | |
| capabilities: CapabilitiesFile, | |
| window: Option<WindowSize>, | |
| } | |
| /// The size an application's window opens at, in logical pixels. | |
| /// | |
| /// Only the opening size: the person using the application can still resize | |
| /// the window, and the host keeps its own default when the manifest is silent. | |
| #[derive(Clone, Copy, Debug, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| pub struct WindowSize { | |
| /// Width in logical pixels. | |
| width: f32, | |
| /// Height in logical pixels. | |
| height: f32, | |
| } | |
| impl WindowSize { | |
| /// Width in logical pixels. | |
| pub fn width(&self) -> f32 { | |
| self.width | |
| } | |
| /// Height in logical pixels. | |
| pub fn height(&self) -> f32 { | |
| self.height | |
| } | |
| fn validate(&self) -> Result<(), ManifestProblem> { | |
| for (name, value) in [("width", self.width), ("height", self.height)] { | |
| if !value.is_finite() || value <= 0. { | |
| return Err(ManifestProblem::Window(format!( | |
| "`{name}` must be a positive number of logical pixels, found {value}" | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| } | |
| /// One JavaScript package fetched from Git before an application starts. | |
| #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(try_from = "GitDependencyFile")] | |
| #[schemars(with = "GitDependencyFile")] | |
| pub struct GitDependency { | |
| git: String, | |
| branch: Option<String>, | |
| tag: Option<String>, | |
| entry: String, | |
| reference: Option<String>, | |
| package_entry: bool, | |
| } | |
| #[derive(Debug, Deserialize, JsonSchema)] | |
| #[serde(untagged)] | |
| enum GitDependencyFile { | |
| Shorthand(String), | |
| Object(GitDependencyObject), | |
| } | |
| #[derive(Debug, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct GitDependencyObject { | |
| git: String, | |
| #[serde(default)] | |
| branch: Option<String>, | |
| #[serde(default)] | |
| tag: Option<String>, | |
| #[serde(default = "default_dependency_entry")] | |
| entry: String, | |
| } | |
| fn default_dependency_entry() -> String { | |
| "index.js".to_owned() | |
| } | |
| impl TryFrom<GitDependencyFile> for GitDependency { | |
| type Error = String; | |
| fn try_from(value: GitDependencyFile) -> Result<Self, Self::Error> { | |
| match value { | |
| GitDependencyFile::Object(object) => Ok(Self { | |
| git: object.git, | |
| branch: object.branch, | |
| tag: object.tag, | |
| entry: object.entry, | |
| reference: None, | |
| package_entry: false, | |
| }), | |
| GitDependencyFile::Shorthand(source) => { | |
| let (git, reference) = parse_git_dependency_string(&source)?; | |
| Ok(Self { | |
| git, | |
| branch: None, | |
| tag: None, | |
| entry: default_dependency_entry(), | |
| reference, | |
| package_entry: true, | |
| }) | |
| } | |
| } | |
| } | |
| } | |
| fn parse_git_dependency_string(source: &str) -> Result<(String, Option<String>), String> { | |
| if source.trim() != source || source.is_empty() || source.matches('#').count() > 1 { | |
| return Err( | |
| "a string dependency must be a Git URL or GitHub owner/repository with one optional #Git ref" | |
| .to_owned(), | |
| ); | |
| } | |
| let (remote, fragment) = match source.split_once('#') { | |
| Some((_remote, "")) => { | |
| return Err("a string dependency #Git ref must not be empty".to_owned()); | |
| } | |
| Some((remote, reference)) => (remote, Some(reference)), | |
| None => (source, None), | |
| }; | |
| if let Some(reference) = fragment | |
| && !valid_git_ref_name(reference) | |
| { | |
| return Err(format!( | |
| "string dependency selector `{reference}` is not a valid Git ref" | |
| )); | |
| } | |
| if remote.contains("://") || looks_like_scp_git_url(remote) { | |
| if remote.chars().any(char::is_whitespace) | |
| || remote.ends_with("://") | |
| || remote.starts_with("://") | |
| { | |
| return Err("a string dependency must contain a valid Git URL".to_owned()); | |
| } | |
| return Ok((remote.to_owned(), fragment.map(str::to_owned))); | |
| } | |
| let mut components = remote.split('/'); | |
| let owner = components.next().unwrap_or_default(); | |
| let repository = components.next().unwrap_or_default(); | |
| if components.next().is_some() | |
| || !valid_github_component(owner) | |
| || !valid_github_component(repository) | |
| { | |
| return Err( | |
| "GitHub shorthand must contain exactly owner/repository plus an optional #Git ref" | |
| .to_owned(), | |
| ); | |
| } | |
| Ok(( | |
| format!("https://github.com/{owner}/{repository}"), | |
| Some(fragment.unwrap_or("main").to_owned()), | |
| )) | |
| } | |
| fn looks_like_scp_git_url(remote: &str) -> bool { | |
| let Some((authority, path)) = remote.split_once(':') else { | |
| return false; | |
| }; | |
| authority.contains('@') && !authority.contains('/') && path.contains('/') && !path.is_empty() | |
| } | |
| fn valid_github_component(component: &str) -> bool { | |
| !component.is_empty() | |
| && component != "." | |
| && component != ".." | |
| && component | |
| .bytes() | |
| .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) | |
| } | |
| impl GitDependency { | |
| pub fn git(&self) -> &str { | |
| &self.git | |
| } | |
| pub fn branch(&self) -> Option<&str> { | |
| self.branch.as_deref() | |
| } | |
| pub fn tag(&self) -> Option<&str> { | |
| self.tag.as_deref() | |
| } | |
| pub fn entry(&self) -> &str { | |
| &self.entry | |
| } | |
| pub(crate) fn reference(&self) -> Option<&str> { | |
| self.reference.as_deref() | |
| } | |
| pub(crate) fn uses_package_entry(&self) -> bool { | |
| self.package_entry | |
| } | |
| } | |
| impl PluginManifest { | |
| /// Parses manifest source. | |
| /// | |
| /// Every failure names the field and says what was expected, because this | |
| /// is the first thing an author of a plugin meets and it is usually the | |
| /// only diagnostic they get: nothing has run yet, so there is no stack | |
| /// trace to fall back on. | |
| pub fn parse(source: &str) -> Result<Self, ManifestError> { | |
| Self::parse_inner(source).map_err(ManifestError::from) | |
| } | |
| /// Reads `<directory>/gpui-shell.json`. | |
| /// | |
| /// The path is carried into the error, because a host reads many manifests | |
| /// in one pass and "missing field `id`" is not actionable without it. | |
| pub fn read(directory: &Path) -> Result<Self, ManifestError> { | |
| let path = directory.join(MANIFEST_FILE); | |
| let source = std::fs::read_to_string(&path).map_err(|error| ManifestError { | |
| path: Some(path.clone()), | |
| problem: ManifestProblem::Unreadable(error.to_string()), | |
| })?; | |
| Self::parse_inner(&source).map_err(|problem| ManifestError { | |
| path: Some(path), | |
| problem, | |
| }) | |
| } | |
| fn parse_inner(source: &str) -> Result<Self, ManifestProblem> { | |
| let value: serde_json::Value = serde_json::from_str(source) | |
| .map_err(|error| ManifestProblem::NotJson(error.to_string()))?; | |
| let serde_json::Value::Object(fields) = value else { | |
| return Err(ManifestProblem::NotAnObject(json_type_of(&value))); | |
| }; | |
| // Unknown fields are rejected before missing ones, so that a typo | |
| // reports itself rather than reporting the field it was meant to be. | |
| // This is the case the design is most exposed to: `"capabilites"` is | |
| // optional-looking, and accepting it would hand the plugin an empty | |
| // grant while its author believes it was granted everything listed. | |
| for field in fields.keys() { | |
| if !FIELDS.contains(&field.as_str()) { | |
| return Err(ManifestProblem::UnknownField { | |
| field: field.clone(), | |
| suggestion: nearest_field(field), | |
| }); | |
| } | |
| } | |
| let id = string_field(&fields, "id")?; | |
| validate_id(&id)?; | |
| let name = string_field(&fields, "name")?; | |
| let version = match fields.get("version") { | |
| None | Some(serde_json::Value::Null) => "unknown".to_owned(), | |
| Some(_) => { | |
| let version = string_field(&fields, "version")?; | |
| validate_version(&version)?; | |
| version | |
| } | |
| }; | |
| let shell_version = match fields.get("shell-version") { | |
| None | Some(serde_json::Value::Null) => SHELL_VERSION.to_owned(), | |
| Some(_) => { | |
| let shell_version = string_field(&fields, "shell-version")?; | |
| validate_shell_version(&shell_version)?; | |
| shell_version | |
| } | |
| }; | |
| let entry = string_field(&fields, "entry")?; | |
| validate_entry(&entry)?; | |
| let dependencies = match fields.get("dependencies") { | |
| None | Some(serde_json::Value::Null) => BTreeMap::new(), | |
| Some(value) => serde_json::from_value::<BTreeMap<String, GitDependency>>(value.clone()) | |
| .map_err(|error| ManifestProblem::Dependencies(error.to_string()))?, | |
| }; | |
| validate_dependencies(&dependencies)?; | |
| // An omitted `capabilities` has the one permission default that cannot | |
| // be an accident: absent means the empty grant | |
| // (§5.7), which is also what an explicit `{}` means. Requiring the key | |
| // would add a line that says "nothing" to every plugin that wants | |
| // nothing. | |
| let capabilities = match fields.get("capabilities") { | |
| None | Some(serde_json::Value::Null) => CapabilitiesFile::default(), | |
| Some(value) => serde_json::from_value(value.clone()) | |
| .map_err(|error| ManifestProblem::Capabilities(error.to_string()))?, | |
| }; | |
| capabilities.validate_placeholders()?; | |
| capabilities.validate_network()?; | |
| capabilities.validate_env()?; | |
| let window = match fields.get("window") { | |
| None | Some(serde_json::Value::Null) => None, | |
| Some(value) => { | |
| let size: WindowSize = serde_json::from_value(value.clone()) | |
| .map_err(|error| ManifestProblem::Window(error.to_string()))?; | |
| size.validate()?; | |
| Some(size) | |
| } | |
| }; | |
| Ok(Self { | |
| id, | |
| name, | |
| version, | |
| shell_version, | |
| entry, | |
| dependencies, | |
| capabilities, | |
| window, | |
| }) | |
| } | |
| /// The namespace this plugin owns. | |
| /// | |
| /// It is the panel-name prefix (`script:<id>/<panel>`, §15.4), the storage | |
| /// key, the log field and the identity a capability approval is recorded | |
| /// against — which is why the manifest parser validates ids strictly. | |
| pub fn id(&self) -> &str { | |
| &self.id | |
| } | |
| /// The human-readable name, for menus and permission sheets. | |
| pub fn name(&self) -> &str { | |
| &self.name | |
| } | |
| /// The plugin's own version, or `unknown` when the manifest omits it. | |
| pub fn version(&self) -> &str { | |
| &self.version | |
| } | |
| /// The oldest gpui-shell release this application requires. | |
| pub fn shell_version(&self) -> &str { | |
| &self.shell_version | |
| } | |
| /// The module evaluated at load, relative to the plugin directory. | |
| pub fn entry(&self) -> &str { | |
| &self.entry | |
| } | |
| /// Git-backed packages available to JavaScript as bare module imports. | |
| pub fn dependencies(&self) -> &BTreeMap<String, GitDependency> { | |
| &self.dependencies | |
| } | |
| /// The size the window opens at, when the manifest names one. | |
| pub fn window(&self) -> Option<WindowSize> { | |
| self.window | |
| } | |
| /// The grant this manifest asks for, resolved against the two directories | |
| /// only the host knows. | |
| /// | |
| /// The manifest writes `${pluginDir}` and `${dataDir}` (§18.1) rather than | |
| /// real paths, for the same reason a plugin cannot name its own storage | |
| /// location: a path chosen by the plugin is a path the plugin can point | |
| /// anywhere. So the *shape* of the grant comes from the manifest and | |
| /// nowhere else, while the two directories it is anchored to come from the | |
| /// host and nowhere else. A relative path is anchored to the plugin | |
| /// directory; an absolute path is taken as written, and is the case a host | |
| /// policy or an approval prompt (§19.2) exists to gate. | |
| /// | |
| /// `${homeDir}` is the user's home directory, read from the environment, | |
| /// so that a grant on the user's own files — `${homeDir}/.config/app` — | |
| /// does not have to hard-code a user name. A path under it is dropped from | |
| /// the grant when the home directory is unknown, rather than anchored | |
| /// somewhere it was not meant to be. | |
| pub fn capabilities(&self, plugin_dir: &Path, data_dir: &Path) -> Capabilities { | |
| self.capabilities.grant(&Anchors { | |
| plugin_dir, | |
| data_dir, | |
| home_dir: crate::runtime::home_dir().as_deref(), | |
| }) | |
| } | |
| } | |
| const FIELDS: [&str; 8] = [ | |
| "id", | |
| "name", | |
| "version", | |
| "shell-version", | |
| "entry", | |
| "dependencies", | |
| "capabilities", | |
| "window", | |
| ]; | |
| fn validate_dependencies( | |
| dependencies: &BTreeMap<String, GitDependency>, | |
| ) -> Result<(), ManifestProblem> { | |
| for (name, dependency) in dependencies { | |
| if name.is_empty() | |
| || name.starts_with(['.', '/']) | |
| || name.contains(['\\', ':']) | |
| || name.split('/').any(|part| part.is_empty() || part == "..") | |
| { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` is not a valid bare module name" | |
| ))); | |
| } | |
| if crate::host_modules::RESERVED_SPECIFIERS.contains(&name.as_str()) { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` is reserved by gpui-shell and cannot name a Git dependency" | |
| ))); | |
| } | |
| if dependency.git.trim().is_empty() { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}.git` must not be empty" | |
| ))); | |
| } | |
| if dependency.uses_package_entry() { | |
| if let Some(reference) = dependency.reference() | |
| && !valid_git_ref_name(reference) | |
| { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` selector `{reference}` is not a valid Git ref" | |
| ))); | |
| } | |
| continue; | |
| } | |
| let reference = match (&dependency.branch, &dependency.tag) { | |
| (Some(branch), None) if !branch.trim().is_empty() => branch, | |
| (None, Some(tag)) if !tag.trim().is_empty() => tag, | |
| (Some(_), Some(_)) => { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` must select either `branch` or `tag`, not both" | |
| ))); | |
| } | |
| _ => { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` must select one non-empty `branch` or `tag`" | |
| ))); | |
| } | |
| }; | |
| if !valid_git_ref_name(reference) { | |
| return Err(ManifestProblem::Dependencies(format!( | |
| "`{name}` selector `{reference}` is not a valid Git ref name" | |
| ))); | |
| } | |
| validate_entry(&dependency.entry).map_err(|_| { | |
| ManifestProblem::Dependencies(format!( | |
| "`{name}.entry` must be a path inside the Git repository" | |
| )) | |
| })?; | |
| } | |
| Ok(()) | |
| } | |
| fn valid_git_ref_name(reference: &str) -> bool { | |
| reference != "@" | |
| && !reference.starts_with(['.', '/']) | |
| && !reference.ends_with(['.', '/']) | |
| && !reference.contains("..") | |
| && !reference.contains("@{") | |
| && !reference.contains("//") | |
| && !reference.chars().any(|character| { | |
| character.is_control() | |
| || character.is_whitespace() | |
| || matches!(character, '~' | '^' | ':' | '?' | '*' | '[' | '\\') | |
| }) | |
| && reference.split('/').all(|component| { | |
| !component.is_empty() && !component.starts_with('.') && !component.ends_with(".lock") | |
| }) | |
| } | |
| fn string_field( | |
| fields: &serde_json::Map<String, serde_json::Value>, | |
| field: &'static str, | |
| ) -> Result<String, ManifestProblem> { | |
| match fields.get(field) { | |
| None | Some(serde_json::Value::Null) => Err(ManifestProblem::MissingField(field)), | |
| Some(serde_json::Value::String(value)) if value.trim().is_empty() => { | |
| Err(ManifestProblem::EmptyField(field)) | |
| } | |
| Some(serde_json::Value::String(value)) => Ok(value.clone()), | |
| Some(other) => Err(ManifestProblem::WrongType { | |
| field, | |
| found: json_type_of(other), | |
| }), | |
| } | |
| } | |
| /// An `id` is used verbatim as a directory name, a panel-name prefix and a log | |
| /// field, so the characters it may contain are decided by the strictest of | |
| /// those three uses rather than by taste. | |
| /// | |
| /// The two rules that are security and not style: no path separators and no | |
| /// `..`, because `data_dir/<id>` must stay inside the data directory; and no | |
| /// uppercase, because two ids differing only in case would be one directory on | |
| /// a case-insensitive filesystem and two everywhere else. | |
| fn validate_id(id: &str) -> Result<(), ManifestProblem> { | |
| if let Some(character) = id | |
| .chars() | |
| .find(|character| !matches!(character, 'a'..='z' | '0'..='9' | '.' | '-' | '_')) | |
| { | |
| return Err(ManifestProblem::InvalidId { | |
| id: id.to_owned(), | |
| reason: format!("`{character}` is not allowed"), | |
| }); | |
| } | |
| let bounded_by_separator = | |
| |value: &str| value.starts_with(['.', '-', '_']) || value.ends_with(['.', '-', '_']); | |
| if bounded_by_separator(id) || id.contains("..") { | |
| return Err(ManifestProblem::InvalidId { | |
| id: id.to_owned(), | |
| reason: "it must begin and end with a letter or a digit".to_owned(), | |
| }); | |
| } | |
| Ok(()) | |
| } | |
| /// When supplied, a plugin version is compared across an upgrade (§19.4: an | |
| /// update that adds capabilities asks again), and comparison needs an agreed | |
| /// shape. Semver's is the one §23 already uses. | |
| fn validate_version(version: &str) -> Result<(), ManifestProblem> { | |
| Version::parse(version) | |
| .map(|_| ()) | |
| .map_err(|_| ManifestProblem::InvalidVersion(version.to_owned())) | |
| } | |
| fn validate_shell_version(version: &str) -> Result<(), ManifestProblem> { | |
| let required = Version::parse(version) | |
| .map_err(|_| ManifestProblem::InvalidShellVersion(version.to_owned()))?; | |
| let runtime = Version::parse(SHELL_VERSION).expect("the crate version is semantic"); | |
| if runtime >= required { | |
| Ok(()) | |
| } else { | |
| Err(ManifestProblem::IncompatibleShellVersion { | |
| required: version.to_owned(), | |
| runtime: SHELL_VERSION.to_owned(), | |
| }) | |
| } | |
| } | |
| /// The entry is resolved inside the plugin directory, so it must be a path that | |
| /// cannot leave it. This is the same rule the module resolver applies to every | |
| /// `import` (§19.1); applying it here means a manifest cannot ask for a file | |
| /// the resolver would refuse anyway. | |
| fn validate_entry(entry: &str) -> Result<(), ManifestProblem> { | |
| let path = Path::new(entry); | |
| let escapes = path.is_absolute() | |
| || path | |
| .components() | |
| .any(|component| matches!(component, std::path::Component::ParentDir)) | |
| || entry.starts_with('\\') | |
| || entry.contains(':'); | |
| if escapes { | |
| Err(ManifestProblem::InvalidEntry(entry.to_owned())) | |
| } else { | |
| Ok(()) | |
| } | |
| } | |
| fn json_type_of(value: &serde_json::Value) -> &'static str { | |
| match value { | |
| serde_json::Value::Null => "null", | |
| serde_json::Value::Bool(_) => "a boolean", | |
| serde_json::Value::Number(_) => "a number", | |
| serde_json::Value::String(_) => "a string", | |
| serde_json::Value::Array(_) => "an array", | |
| serde_json::Value::Object(_) => "an object", | |
| } | |
| } | |
| /// The closest known field name, so a typo is answered with the word the author | |
| /// meant rather than with a list they have to scan. | |
| fn nearest_field(unknown: &str) -> Option<&'static str> { | |
| FIELDS | |
| .iter() | |
| .map(|field| (edit_distance(field, unknown), *field)) | |
| .filter(|(distance, _)| *distance <= 3) | |
| .min_by_key(|(distance, _)| *distance) | |
| .map(|(_, field)| field) | |
| } | |
| fn edit_distance(left: &str, right: &str) -> usize { | |
| let right: Vec<char> = right.chars().collect(); | |
| let mut previous: Vec<usize> = (0..=right.len()).collect(); | |
| let mut current = vec![0; right.len() + 1]; | |
| for (row, left_character) in left.chars().enumerate() { | |
| current[0] = row + 1; | |
| for (column, right_character) in right.iter().enumerate() { | |
| let substitution = usize::from(left_character != *right_character); | |
| current[column + 1] = (previous[column] + substitution) | |
| .min(previous[column + 1] + 1) | |
| .min(current[column] + 1); | |
| } | |
| std::mem::swap(&mut previous, &mut current); | |
| } | |
| previous[right.len()] | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // The file form of the manifest | |
| // ----------------------------------------------------------------------------- | |
| /// The serde and schemars view of a manifest. | |
| /// | |
| /// It exists only so `schemars` can describe the file; parsing goes through | |
| /// [`PluginManifest::parse`], which reports one field at a time. Keeping the | |
| /// two in one module is what stops the schema from describing a file the | |
| /// parser would reject. | |
| // The fields are never read through this type — parsing goes field by field so | |
| // each failure can carry its own explanation — but they are what `schemars` | |
| // walks, and they are read by the test that keeps this type and the parser | |
| // agreeing. | |
| #[allow(dead_code)] | |
| #[derive(Debug, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct ManifestFile { | |
| /// Reverse-DNS identity, e.g. `com.example.inbox`. Also the namespace for | |
| /// panels, storage and capability records. | |
| id: String, | |
| /// Human-readable name, shown in menus and in the permission prompt. | |
| name: String, | |
| /// Optional plugin semantic version, e.g. `1.2.0`. | |
| #[serde(default)] | |
| version: Option<String>, | |
| /// Optional oldest compatible gpui-shell release. Checked before `entry` executes. | |
| #[serde(default, rename = "shell-version")] | |
| shell_version: Option<String>, | |
| /// The module evaluated at load, relative to the plugin directory. | |
| entry: String, | |
| /// Git-backed packages imported by their map key from JavaScript. | |
| #[serde(default)] | |
| dependencies: BTreeMap<String, GitDependency>, | |
| /// What the plugin is allowed to do. Absent means nothing but its own | |
| /// storage — see [`CapabilitiesFile::storage`]. | |
| #[serde(default)] | |
| capabilities: CapabilitiesFile, | |
| /// The size the window opens at, e.g. `{"width": 360, "height": 520}`. | |
| /// Absent means the host's default. | |
| #[serde(default)] | |
| window: Option<WindowSize>, | |
| } | |
| /// The `capabilities` block, before it is anchored to real directories. | |
| #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct CapabilitiesFile { | |
| /// Filesystem and subprocess access. | |
| #[serde(default)] | |
| fs: Option<FsGrantFile>, | |
| /// Outbound network access, by host. | |
| #[serde(default)] | |
| network: Option<NetworkGrantFile>, | |
| /// Whether `localStorage` is available. | |
| /// | |
| /// The one grant that defaults to *given*, and it follows the web: a | |
| /// browser hands every origin a `localStorage` unconditionally, because | |
| /// what it reaches is that origin's own data and nothing else. The same | |
| /// holds here — storage is keyed by bundle id, cannot name its own file, | |
| /// and is bounded — so there is nothing for an author to ask permission | |
| /// for. Defaulting it to `false` also had a trap: an application that | |
| /// added a manifest to declare a network host silently lost its settings. | |
| /// | |
| /// A host that runs code it does not trust can still say `false`, which is | |
| /// why this stays a capability rather than becoming ambient like | |
| /// `sessionStorage`. | |
| #[serde(default = "granted")] | |
| storage: bool, | |
| /// Clipboard access. | |
| #[serde(default)] | |
| clipboard: Option<ClipboardGrantFile>, | |
| /// Process-level host requests, separate from filesystem execution. | |
| #[serde(default)] | |
| process: Option<ProcessGrantFile>, | |
| } | |
| #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct FsGrantFile { | |
| /// Directories that may be read. `${pluginDir}`, `${dataDir}` and | |
| /// `${homeDir}` expand to the plugin's own directory, its storage directory | |
| /// and the user's home directory; a relative path is anchored to the plugin | |
| /// directory, and an absolute one is taken as written. | |
| #[serde(default)] | |
| read: Vec<String>, | |
| /// Directories that may be written, with the same placeholders as `read`. | |
| #[serde(default)] | |
| write: Vec<String>, | |
| /// Commands `process.run` may start. | |
| #[serde(default)] | |
| execute: Option<ExecuteFile>, | |
| } | |
| /// Either an allowlist of command names, or the string `"*"`. | |
| /// | |
| /// Unrestricted execution has to be spellable — a host that cannot express it | |
| /// pushes its users to grant a wildcard read root instead, which is worse — but | |
| /// it is spelled differently from an allowlist so that a permission sheet can | |
| /// show it at the severity it deserves (§19.2). | |
| #[derive(Clone, Debug, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(untagged)] | |
| enum ExecuteFile { | |
| Allowed(Vec<String>), | |
| Unrestricted(String), | |
| } | |
| #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct NetworkGrantFile { | |
| /// Hosts that may be reached, e.g. `api.example.com`. | |
| #[serde(default)] | |
| hosts: Vec<String>, | |
| /// HTTP requests constrained by host, method and URL path. | |
| #[serde(default)] | |
| http: Vec<HttpRequestGrantFile>, | |
| } | |
| #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct HttpRequestGrantFile { | |
| #[serde(default = "default_https_scheme")] | |
| scheme: String, | |
| host: String, | |
| #[serde(default)] | |
| port: Option<u16>, | |
| methods: Vec<String>, | |
| #[serde(default)] | |
| paths: Vec<String>, | |
| #[serde(default)] | |
| path_prefixes: Vec<String>, | |
| } | |
| fn default_https_scheme() -> String { | |
| "https".to_owned() | |
| } | |
| #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct ClipboardGrantFile { | |
| #[serde(default)] | |
| read: bool, | |
| #[serde(default)] | |
| write: bool, | |
| } | |
| #[derive(Clone, Debug, Default, PartialEq, Deserialize, JsonSchema)] | |
| #[serde(deny_unknown_fields)] | |
| struct ProcessGrantFile { | |
| /// Whether `process.exit()` may ask the host to exit. | |
| #[serde(default)] | |
| exit: bool, | |
| /// Environment variables `process.env` shows, by name. Every other | |
| /// variable is absent, because the environment routinely holds tokens and | |
| /// keys meant for the host rather than for every script it runs. | |
| #[serde(default)] | |
| env: Vec<String>, | |
| } | |
| const PLUGIN_DIR_PLACEHOLDER: &str = "${pluginDir}"; | |
| const DATA_DIR_PLACEHOLDER: &str = "${dataDir}"; | |
| const HOME_DIR_PLACEHOLDER: &str = "${homeDir}"; | |
| /// The directories a manifest's placeholders expand to. | |
| struct Anchors<'a> { | |
| plugin_dir: &'a Path, | |
| data_dir: &'a Path, | |
| home_dir: Option<&'a Path>, | |
| } | |
| fn granted() -> bool { | |
| true | |
| } | |
| /// Written out rather than derived, because a derived `Default` would ignore | |
| /// the `serde` field defaults above — and then an absent `capabilities` block | |
| /// and a block that merely omits `storage` would disagree about storage. They | |
| /// have to be the same answer, or "does declaring a network host cost me my | |
| /// settings?" depends on whether the block exists at all. | |
| impl Default for CapabilitiesFile { | |
| fn default() -> Self { | |
| Self { | |
| fs: None, | |
| network: None, | |
| storage: granted(), | |
| clipboard: None, | |
| process: None, | |
| } | |
| } | |
| } | |
| impl CapabilitiesFile { | |
| fn grant(&self, anchors: &Anchors) -> Capabilities { | |
| let fs = self.fs.clone().unwrap_or_default(); | |
| let clipboard = self.clipboard.clone().unwrap_or_default(); | |
| let process = self.process.clone().unwrap_or_default(); | |
| let execute = match fs.execute.clone() { | |
| None => ExecuteGrant::Denied, | |
| Some(ExecuteFile::Unrestricted(_)) => ExecuteGrant::Unrestricted, | |
| Some(ExecuteFile::Allowed(commands)) => ExecuteGrant::Allowed(commands), | |
| }; | |
| let network = self.network.clone().unwrap_or_default(); | |
| Capabilities::new() | |
| .read_roots(expand_all(&fs.read, anchors)) | |
| .write_roots(expand_all(&fs.write, anchors)) | |
| .execute(execute) | |
| .network_hosts(network.hosts.into_iter().map(|host| host.to_lowercase())) | |
| .http_requests(network.http.into_iter().map(|request| { | |
| let mut grant = HttpRequestGrant::new( | |
| request.host, | |
| request.methods, | |
| request.paths, | |
| request.path_prefixes, | |
| ) | |
| .scheme(request.scheme); | |
| if let Some(port) = request.port { | |
| grant = grant.port(port); | |
| } | |
| grant | |
| })) | |
| .storage(self.storage) | |
| .clipboard_read(clipboard.read) | |
| .clipboard_write(clipboard.write) | |
| .exit(process.exit) | |
| .env_variables(process.env) | |
| } | |
| /// A placeholder the host does not expand would otherwise reach | |
| /// [`Capabilities`] as the literal directory name `${dataDir}`, and grant | |
| /// access to a directory that does not exist. Catching it at parse time | |
| /// makes it a manifest error, which is where an author can see it. | |
| fn validate_placeholders(&self) -> Result<(), ManifestProblem> { | |
| let Some(fs) = &self.fs else { | |
| return Ok(()); | |
| }; | |
| for (field, paths) in [("read", &fs.read), ("write", &fs.write)] { | |
| for path in paths { | |
| if let Some(problem) = unexpanded_home(path) { | |
| return Err(ManifestProblem::UnexpandedPath { | |
| field: format!("capabilities.fs.{field}"), | |
| path: path.clone(), | |
| suggestion: problem, | |
| }); | |
| } | |
| if let Some(placeholder) = unknown_placeholder(path) { | |
| return Err(ManifestProblem::UnknownPlaceholder { | |
| field: format!("capabilities.fs.{field}"), | |
| placeholder, | |
| }); | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_env(&self) -> Result<(), ManifestProblem> { | |
| let Some(process) = &self.process else { | |
| return Ok(()); | |
| }; | |
| for name in &process.env { | |
| if name.is_empty() || name.contains(['=', '\0']) { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "process.env entry `{name}` must be an environment variable name" | |
| ))); | |
| } | |
| } | |
| Ok(()) | |
| } | |
| fn validate_network(&self) -> Result<(), ManifestProblem> { | |
| let Some(network) = &self.network else { | |
| return Ok(()); | |
| }; | |
| for host in network | |
| .hosts | |
| .iter() | |
| .chain(network.http.iter().map(|rule| &rule.host)) | |
| { | |
| if host.is_empty() || host.contains("://") || host.contains('/') { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "network host `{host}` must be a hostname without a scheme or path" | |
| ))); | |
| } | |
| } | |
| for (index, rule) in network.http.iter().enumerate() { | |
| if !matches!(rule.scheme.as_str(), "http" | "https") { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "network.http[{index}].scheme must be `http` or `https`" | |
| ))); | |
| } | |
| if rule.methods.is_empty() { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "network.http[{index}].methods must contain at least one HTTP method" | |
| ))); | |
| } | |
| for method in &rule.methods { | |
| if !matches!(method.as_str(), "GET" | "POST") { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "network.http[{index}].methods contains invalid HTTP method `{method}`" | |
| ))); | |
| } | |
| } | |
| for (field, paths) in [ | |
| ("paths", &rule.paths), | |
| ("path_prefixes", &rule.path_prefixes), | |
| ] { | |
| for path in paths { | |
| if !path.starts_with('/') { | |
| return Err(ManifestProblem::Capabilities(format!( | |
| "network.http[{index}].{field} entry `{path}` must start with `/`" | |
| ))); | |
| } | |
| } | |
| } | |
| } | |
| Ok(()) | |
| } | |
| } | |
| /// Spellings of the home directory a shell or an environment would expand, | |
| /// which a manifest does not. | |
| /// | |
| /// The manifest's own spelling is `${homeDir}`, but `$HOME`, `${HOME}` and `~` | |
| /// are what an author types from habit. `${HOME}` would at least be refused as | |
| /// an unknown placeholder; the other two are not placeholders at all, so they | |
| /// would quietly become a relative directory such as `<app>/$HOME/.claude` | |
| /// and grant nothing. Each one is caught here with the path rewritten. | |
| const HOME_SPELLINGS: [&str; 6] = [ | |
| "${HOME}", | |
| "$HOME", | |
| "${USERPROFILE}", | |
| "$USERPROFILE", | |
| "%USERPROFILE%", | |
| "~", | |
| ]; | |
| /// The path rewritten with `${homeDir}` when it starts with a home spelling | |
| /// the manifest does not expand, and `None` otherwise. | |
| fn unexpanded_home(path: &str) -> Option<String> { | |
| HOME_SPELLINGS.iter().find_map(|spelling| { | |
| let rest = path.strip_prefix(spelling)?; | |
| (rest.is_empty() || rest.starts_with(['/', '\\'])) | |
| .then(|| format!("{HOME_DIR_PLACEHOLDER}{rest}")) | |
| }) | |
| } | |
| fn unknown_placeholder(value: &str) -> Option<String> { | |
| let mut rest = value; | |
| while let Some(start) = rest.find("${") { | |
| let tail = &rest[start..]; | |
| let end = tail.find('}')? + 1; | |
| let placeholder = &tail[..end]; | |
| if ![ | |
| PLUGIN_DIR_PLACEHOLDER, | |
| DATA_DIR_PLACEHOLDER, | |
| HOME_DIR_PLACEHOLDER, | |
| ] | |
| .contains(&placeholder) | |
| { | |
| return Some(placeholder.to_owned()); | |
| } | |
| rest = &tail[end..]; | |
| } | |
| None | |
| } | |
| fn expand_all(paths: &[String], anchors: &Anchors) -> Vec<PathBuf> { | |
| paths | |
| .iter() | |
| .filter_map(|path| expand(path, anchors)) | |
| .collect() | |
| } | |
| /// `None` for a path under `${homeDir}` when there is no home directory: left | |
| /// in, the placeholder would become a relative directory inside the plugin. | |
| fn expand(raw: &str, anchors: &Anchors) -> Option<PathBuf> { | |
| let mut expanded = raw | |
| .replace( | |
| PLUGIN_DIR_PLACEHOLDER, | |
| anchors.plugin_dir.to_string_lossy().as_ref(), | |
| ) | |
| .replace( | |
| DATA_DIR_PLACEHOLDER, | |
| anchors.data_dir.to_string_lossy().as_ref(), | |
| ); | |
| if expanded.contains(HOME_DIR_PLACEHOLDER) { | |
| expanded = expanded.replace( | |
| HOME_DIR_PLACEHOLDER, | |
| anchors.home_dir?.to_string_lossy().as_ref(), | |
| ); | |
| } | |
| let path = PathBuf::from(expanded); | |
| Some(if path.is_absolute() { | |
| path | |
| } else { | |
| anchors.plugin_dir.join(path) | |
| }) | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Errors | |
| // ----------------------------------------------------------------------------- | |
| /// Why a manifest could not be used, and — when it was read from disk — which | |
| /// file it was. | |
| /// | |
| /// The path is a separate field rather than a variant so that every problem | |
| /// gains it without the enum doubling in size, and so a caller can match on the | |
| /// problem without unwrapping a location first. | |
| #[derive(Debug, PartialEq)] | |
| pub struct ManifestError { | |
| path: Option<PathBuf>, | |
| problem: ManifestProblem, | |
| } | |
| impl ManifestError { | |
| /// The manifest file, when the manifest came from one. | |
| pub fn path(&self) -> Option<&Path> { | |
| self.path.as_deref() | |
| } | |
| pub fn problem(&self) -> &ManifestProblem { | |
| &self.problem | |
| } | |
| } | |
| impl From<ManifestProblem> for ManifestError { | |
| fn from(problem: ManifestProblem) -> Self { | |
| Self { | |
| path: None, | |
| problem, | |
| } | |
| } | |
| } | |
| impl std::fmt::Display for ManifestError { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| match &self.path { | |
| Some(path) => write!(f, "{}: {}", path.display(), self.problem), | |
| None => self.problem.fmt(f), | |
| } | |
| } | |
| } | |
| impl std::error::Error for ManifestError {} | |
| /// What was wrong with a manifest. | |
| /// | |
| /// Wording follows `CapabilityError`: say what is wrong, then say what to | |
| /// write instead. A plugin author reading one of these has no other diagnostic | |
| /// available — nothing has run yet. | |
| #[derive(Debug, PartialEq)] | |
| pub enum ManifestProblem { | |
| Unreadable(String), | |
| NotJson(String), | |
| NotAnObject(&'static str), | |
| MissingField(&'static str), | |
| EmptyField(&'static str), | |
| WrongType { | |
| field: &'static str, | |
| found: &'static str, | |
| }, | |
| UnknownField { | |
| field: String, | |
| suggestion: Option<&'static str>, | |
| }, | |
| InvalidId { | |
| id: String, | |
| reason: String, | |
| }, | |
| DuplicateId { | |
| id: String, | |
| first: PathBuf, | |
| }, | |
| InvalidVersion(String), | |
| InvalidShellVersion(String), | |
| IncompatibleShellVersion { | |
| required: String, | |
| runtime: String, | |
| }, | |
| InvalidEntry(String), | |
| UnknownPlaceholder { | |
| field: String, | |
| placeholder: String, | |
| }, | |
| /// A path that starts with a shell's spelling of the home directory, with | |
| /// the same path written the way the manifest expands it. | |
| UnexpandedPath { | |
| field: String, | |
| path: String, | |
| suggestion: String, | |
| }, | |
| Capabilities(String), | |
| Dependencies(String), | |
| Window(String), | |
| } | |
| /// What each field is for, in one line, appended to the error that reports it | |
| /// missing. A field name alone tells an author which key to add but not what to | |
| /// put in it. | |
| fn field_expectation(field: &str) -> &'static str { | |
| match field { | |
| "id" => { | |
| "a reverse-DNS identifier such as \"com.example.inbox\"; it is also the plugin's namespace for panels, storage and permissions" | |
| } | |
| "name" => { | |
| "a human-readable name such as \"Inbox\", shown in menus and in the permission prompt" | |
| } | |
| "version" => "the plugin's own semantic version such as \"1.2.0\"", | |
| "shell-version" => "the oldest compatible gpui-shell semantic version, such as \"0.1.0\"", | |
| "entry" => { | |
| "the module to evaluate at load, such as \"main.js\", relative to the plugin directory" | |
| } | |
| _ => "a value", | |
| } | |
| } | |
| impl std::fmt::Display for ManifestProblem { | |
| fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| match self { | |
| ManifestProblem::Unreadable(error) => { | |
| write!(f, "cannot read the manifest: {error}") | |
| } | |
| ManifestProblem::NotJson(error) => { | |
| write!(f, "the manifest is not valid JSON: {error}") | |
| } | |
| ManifestProblem::NotAnObject(found) => write!( | |
| f, | |
| "the manifest must be a JSON object with the fields {}, found {found}", | |
| FIELDS.join(", ") | |
| ), | |
| ManifestProblem::MissingField(field) => write!( | |
| f, | |
| "missing field `{field}`: expected {}", | |
| field_expectation(field) | |
| ), | |
| ManifestProblem::EmptyField(field) => write!( | |
| f, | |
| "field `{field}` is empty: expected {}", | |
| field_expectation(field) | |
| ), | |
| ManifestProblem::WrongType { field, found } => write!( | |
| f, | |
| "field `{field}` must be a string, found {found}: expected {}", | |
| field_expectation(field) | |
| ), | |
| ManifestProblem::UnknownField { field, suggestion } => { | |
| write!(f, "unknown field `{field}`")?; | |
| if let Some(suggestion) = suggestion { | |
| write!(f, "; did you mean `{suggestion}`?")?; | |
| } | |
| write!( | |
| f, | |
| " A manifest has exactly the fields {}. Commands, panels, keybindings, settings and themes are registered in script, not declared here.", | |
| FIELDS.join(", ") | |
| ) | |
| } | |
| ManifestProblem::InvalidId { id, reason } => write!( | |
| f, | |
| "invalid `id` \"{id}\": {reason}; an id may contain lowercase letters, digits, `.`, `-` and `_`, because it is used verbatim as a panel name prefix (script:<id>/<panel>) and as a directory name under the user's data directory" | |
| ), | |
| ManifestProblem::DuplicateId { id, first } => write!( | |
| f, | |
| "`{id}` is already provided by {}; a plugin id is a namespace, and two plugins sharing one would share their storage, their panel names and their permissions", | |
| first.display() | |
| ), | |
| ManifestProblem::InvalidVersion(version) => write!( | |
| f, | |
| "invalid `version` \"{version}\": expected a semantic version such as \"1.2.0\"" | |
| ), | |
| ManifestProblem::InvalidShellVersion(version) => write!( | |
| f, | |
| "invalid `shell-version` \"{version}\": expected a semantic version such as \"0.1.0\"" | |
| ), | |
| ManifestProblem::IncompatibleShellVersion { required, runtime } => write!( | |
| f, | |
| "this application requires gpui-shell {required}, but this runtime is {runtime} and is not compatible" | |
| ), | |
| ManifestProblem::InvalidEntry(entry) => write!( | |
| f, | |
| "invalid `entry` \"{entry}\": expected a path inside the plugin directory, such as \"main.js\"; an absolute path or one containing `..` is refused for the same reason an `import` of one is" | |
| ), | |
| ManifestProblem::UnknownPlaceholder { field, placeholder } => write!( | |
| f, | |
| "unknown placeholder `{placeholder}` in {field}: the manifest may use {PLUGIN_DIR_PLACEHOLDER}, {DATA_DIR_PLACEHOLDER} and {HOME_DIR_PLACEHOLDER}" | |
| ), | |
| ManifestProblem::UnexpandedPath { | |
| field, | |
| path, | |
| suggestion, | |
| } => write!( | |
| f, | |
| "\"{path}\" in {field} is not expanded: the manifest does not read environment variables or `~`; write \"{suggestion}\"" | |
| ), | |
| ManifestProblem::Capabilities(error) => write!( | |
| f, | |
| "invalid `capabilities`: {error}. The block accepts fs (read, write, execute), network (hosts, http), storage, clipboard (read, write) and process (exit)" | |
| ), | |
| ManifestProblem::Dependencies(error) => write!( | |
| f, | |
| "invalid `dependencies`: {error}. Use a GitHub owner/repository shorthand or full Git URL with an optional #ref, or an object with a Git URL, exactly one non-empty `branch` or `tag`, and an optional repository-relative `entry`" | |
| ), | |
| ManifestProblem::Window(error) => write!( | |
| f, | |
| "invalid `window`: {error}. Expected an object such as {{\"width\": 360, \"height\": 520}}, in logical pixels" | |
| ), | |
| } | |
| } | |
| } | |
| // ----------------------------------------------------------------------------- | |
| // Loaded plugins | |
| // ----------------------------------------------------------------------------- | |
| /// One installed plugin: its manifest, where it lives, and — once loaded — what | |
| /// it turned into. | |
| pub struct Plugin { | |
| manifest: PluginManifest, | |
| root: PathBuf, | |
| data_dir: PathBuf, | |
| store_path: PathBuf, | |
| /// The authority this plugin's code runs under, built once at load from its | |
| /// manifest and then never swapped. Callbacks it registers capture it, so a | |
| /// timer firing inside plugin A cannot run with plugin B's grant. | |
| policy: Rc<Policy>, | |
| runtime: Rc<ShellRuntime>, | |
| application: Option<Rc<crate::runtime::ApplicationGeneration>>, | |
| view: Entity<ScriptView>, | |
| } | |
| impl Plugin { | |
| pub fn manifest(&self) -> &PluginManifest { | |
| &self.manifest | |
| } | |
| pub fn id(&self) -> &str { | |
| self.manifest.id() | |
| } | |
| /// The directory the manifest was read from. | |
| pub fn root(&self) -> &Path { | |
| &self.root | |
| } | |
| /// Where this plugin's data lives, keyed by `id` so it survives an upgrade | |
| /// that moves the plugin directory. | |
| pub fn data_dir(&self) -> &Path { | |
| &self.data_dir | |
| } | |
| /// The file behind `localStorage`. | |
| pub fn store_path(&self) -> &Path { | |
| &self.store_path | |
| } | |
| /// The grant in force while this plugin runs. It comes from the manifest | |
| /// and nowhere else. | |
| pub fn capabilities(&self) -> &Capabilities { | |
| self.policy.capabilities() | |
| } | |
| /// The view the entry module default-exported. | |
| pub fn view(&self) -> &Entity<ScriptView> { | |
| &self.view | |
| } | |
| fn shutdown(mut self) { | |
| if let Some(application) = self.application.take() { | |
| self.runtime | |
| .release_application_generation_without_context(&application); | |
| } | |
| crate::engine::quickjs::cancel_policy_tasks(&self.policy); | |
| } | |
| } | |
| fn load_plugin( | |
| runtime: &Rc<ShellRuntime>, | |
| manifest: PluginManifest, | |
| root: PathBuf, | |
| data_dir: PathBuf, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Result<Plugin> { | |
| let id = manifest.id().to_owned(); | |
| let store_path = data_dir.join("store.json"); | |
| if let Err(error) = std::fs::create_dir_all(&data_dir) { | |
| tracing::warn!( | |
| "storage is unavailable for `{id}`: cannot create {}: {error}", | |
| data_dir.display() | |
| ); | |
| } | |
| let policy = Rc::new( | |
| Policy::default() | |
| // The manifest id is already unique among loaded plugins, which is | |
| // exactly what a dock layout needs to keep two plugins' panels of | |
| // the same name apart. | |
| .with_application(&id) | |
| .with_capabilities(manifest.capabilities(&root, &data_dir)) | |
| .with_data_dir(data_dir.clone()) | |
| .with_storage_path(store_path.clone()), | |
| ); | |
| let view = load_view_with_policy(runtime, &root, manifest.entry(), policy.clone(), window, cx) | |
| .with_context(|| format!("loading application `{id}`"))?; | |
| let application = view.read(cx).application_generation(); | |
| Ok(Plugin { | |
| manifest, | |
| root, | |
| data_dir, | |
| store_path, | |
| policy, | |
| runtime: runtime.clone(), | |
| application, | |
| view, | |
| }) | |
| } | |
| fn load_view_with_policy( | |
| runtime: &Rc<ShellRuntime>, | |
| root: &Path, | |
| entry: &str, | |
| policy: Rc<Policy>, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Result<Entity<ScriptView>> { | |
| let loaded = { | |
| let (_scope, _) = | |
| scope::enter_with_runtime(runtime, window, cx, ScopePhase::Task, None, policy.clone()); | |
| runtime.load_app(root, entry).and_then(|view_type| { | |
| runtime.instantiate_view_with_policy(&view_type, policy.clone(), window, cx) | |
| }) | |
| }; | |
| if loaded.is_err() { | |
| crate::engine::quickjs::cancel_policy_tasks(&policy); | |
| } | |
| loaded | |
| } | |
| struct ApplicationLoadFailure { | |
| message: String, | |
| } | |
| impl Render for ApplicationLoadFailure { | |
| fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | |
| failure_surface( | |
| "This application could not be loaded", | |
| &self.message, | |
| "Fix the application and start it again.", | |
| window, | |
| cx, | |
| ) | |
| } | |
| } | |
| impl ShellRuntime { | |
| /// Loads one application as this window's root view. | |
| /// | |
| /// The common single-application host needs no plugin discovery or id | |
| /// lookup. A manifest selects identity and entry metadata, but its | |
| /// capability block is only a request: this method always uses the policy | |
| /// the host installed. A load error becomes the normal selectable failure | |
| /// surface so a bad script cannot crash the host while constructing its | |
| /// window. | |
| pub fn load( | |
| self: &Rc<Self>, | |
| root: impl AsRef<Path>, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Entity<ShellRoot> { | |
| match self.try_load(root, window, cx) { | |
| Ok(root) => root, | |
| Err(error) => load_failure_root(error.to_string(), window, cx), | |
| } | |
| } | |
| /// Loads one application and preserves a structured error for hosts that | |
| /// do not want the convenience failure surface returned by [`Self::load`]. | |
| pub fn try_load( | |
| self: &Rc<Self>, | |
| root: impl AsRef<Path>, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Result<Entity<ShellRoot>> { | |
| let root = root.as_ref().to_path_buf(); | |
| let manifest_path = root.join(MANIFEST_FILE); | |
| let entry = if manifest_path.is_file() { | |
| let manifest = PluginManifest::read(&root)?; | |
| manifest.entry().to_owned() | |
| } else { | |
| "main.js".to_owned() | |
| }; | |
| let root = crate::runtime::resolve_app_root(&root, &entry)?; | |
| let policy = Rc::new(crate::policy::default().duplicate()); | |
| let view = load_view_with_policy(self, &root, &entry, policy.clone(), window, cx)?; | |
| Ok(cx.new(|cx| ShellRoot::with_application(view, root, entry, policy, window, cx))) | |
| } | |
| /// Loads an application, builds its description, and materializes its | |
| /// eager native elements once, returning the checked description. | |
| /// | |
| /// Source checks use this with a hidden window. It keeps engine | |
| /// handles and the `ScriptView` construction protocol inside the shell | |
| /// facade while preserving structured load, render, and registered-component | |
| /// materialization errors. It does not run layout, paint, deferred slots, | |
| /// nested view renders, or later interaction and asynchronous states. | |
| pub fn check( | |
| self: &Rc<Self>, | |
| root: impl AsRef<Path>, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Result<String> { | |
| let root = self.try_load(root, window, cx)?; | |
| let view = root | |
| .read(cx) | |
| .application() | |
| .expect("try_load always mounts an application") | |
| .view | |
| .clone(); | |
| let object = view.read(cx).object().clone(); | |
| let policy = view.read(cx).policy(); | |
| let snapshot = self.build_snapshot(&object, Some(view), policy, window, cx)?; | |
| let tree = snapshot.debug_tree(); | |
| // Materialized during a real frame, as every frame is: native element | |
| // state — a scroll position, retained component state — belongs to the | |
| // view being drawn, and GPUI only has one while it draws. The probe | |
| // becomes the hidden window's root for this one frame and holds the | |
| // application's callback-owning snapshot until the native elements | |
| // have been built and dropped. | |
| let outcome = Rc::new(std::cell::RefCell::new(None)); | |
| let runtime = self.clone(); | |
| let sink = outcome.clone(); | |
| window.replace_root(cx, move |_, _| MaterializeProbe { | |
| runtime, | |
| snapshot, | |
| outcome: sink, | |
| materialized: false, | |
| }); | |
| window.draw(cx).clear(cx); | |
| outcome | |
| .borrow_mut() | |
| .take() | |
| .unwrap_or_else(|| Err(anyhow::anyhow!("the application was never materialized")))?; | |
| Ok(tree) | |
| } | |
| } | |
| /// Materializes one snapshot from inside its own render, for [`ShellRuntime::check`]. | |
| struct MaterializeProbe { | |
| runtime: Rc<ShellRuntime>, | |
| snapshot: crate::snapshot::RenderSnapshot, | |
| outcome: Rc<std::cell::RefCell<Option<Result<()>>>>, | |
| materialized: bool, | |
| } | |
| impl Render for MaterializeProbe { | |
| fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { | |
| // The window may draw again after the check has read its outcome; | |
| // the check is one materialization, as it was before it ran inside a | |
| // frame. | |
| if self.materialized { | |
| return gpui::Empty; | |
| } | |
| self.materialized = true; | |
| let materialized = | |
| crate::materialize::try_materialize(&self.runtime, &self.snapshot, window, cx) | |
| .map(drop); | |
| *self.outcome.borrow_mut() = Some(materialized); | |
| gpui::Empty | |
| } | |
| } | |
| fn load_failure_root(message: String, window: &mut Window, cx: &mut App) -> Entity<ShellRoot> { | |
| tracing::error!("{message}"); | |
| let failure = cx.new(|_| ApplicationLoadFailure { message }); | |
| cx.new(|cx| ShellRoot::new(failure.into(), window, cx)) | |
| } | |
| /// Discovers, loads and unloads plugins. | |
| /// | |
| /// Every loaded plugin holds its own [`Policy`] — its grant, its storage, its | |
| /// host modules — and every call into its code runs under that policy because | |
| /// the policy travels on the call frame rather than in a process-wide slot. Two | |
| /// plugins loaded at once hold two different grants at the same time, and | |
| /// neither can see the other's files. | |
| /// | |
| /// This used to be one slot with a guard around each call, and the guard could | |
| /// not be made correct: a plugin that `await`s hands control back before its | |
| /// guard drops, so the grant in force during the continuation was whichever | |
| /// plugin happened to be running when the promise resolved. Time is what the | |
| /// swap could not account for. Authority now belongs to the code rather than to | |
| /// the moment. | |
| pub struct PluginManager { | |
| directories: Vec<PathBuf>, | |
| data_home: PathBuf, | |
| catalog: Vec<CatalogEntry>, | |
| discovered: bool, | |
| loaded: BTreeMap<String, Plugin>, | |
| } | |
| struct CatalogEntry { | |
| manifest: PluginManifest, | |
| root: PathBuf, | |
| } | |
| impl PluginManager { | |
| /// `directories` are searched in order; an earlier directory wins a | |
| /// duplicate `id`, which is what lets a user's own copy shadow a bundled | |
| /// one. | |
| pub fn new(directories: Vec<PathBuf>) -> Self { | |
| Self { | |
| directories, | |
| data_home: default_data_home(), | |
| catalog: Vec::new(), | |
| discovered: false, | |
| loaded: BTreeMap::new(), | |
| } | |
| } | |
| /// Overrides where plugin data lives. A host that keeps a portable profile | |
| /// needs this, and so does a test that must not touch the real one. | |
| pub fn with_data_home(mut self, path: PathBuf) -> Self { | |
| self.data_home = path; | |
| self | |
| } | |
| pub fn directories(&self) -> &[PathBuf] { | |
| &self.directories | |
| } | |
| /// Reads every manifest found under the configured directories. | |
| /// | |
| /// **Nothing is executed.** This is the whole point of the manifest: a host | |
| /// can show thirty plugins, their versions and the permissions each asks | |
| /// for, having started none of them. | |
| /// | |
| /// Both outcomes are returned in one list rather than the whole pass | |
| /// failing on the first bad manifest — one broken plugin must not hide the | |
| /// twenty-nine working ones, and the broken one still has to be reportable. | |
| /// Results are ordered by path so a listing does not reshuffle itself | |
| /// between runs. | |
| pub fn discover(&mut self) -> Vec<Result<PluginManifest, ManifestError>> { | |
| self.catalog.clear(); | |
| self.discovered = true; | |
| let mut results = Vec::new(); | |
| let mut seen: BTreeMap<String, PathBuf> = BTreeMap::new(); | |
| for directory in self.directories.clone() { | |
| for root in plugin_roots(&directory) { | |
| let manifest = match PluginManifest::read(&root) { | |
| Ok(manifest) => manifest, | |
| Err(error) => { | |
| results.push(Err(error)); | |
| continue; | |
| } | |
| }; | |
| // First directory wins, and the loser is reported rather | |
| // than dropped: a shadowed plugin that simply never appears is | |
| // the hardest kind of install problem to diagnose. | |
| if let Some(first) = seen.get(manifest.id()) { | |
| results.push(Err(ManifestError { | |
| path: Some(root.join(MANIFEST_FILE)), | |
| problem: ManifestProblem::DuplicateId { | |
| id: manifest.id().to_owned(), | |
| first: first.clone(), | |
| }, | |
| })); | |
| continue; | |
| } | |
| seen.insert(manifest.id().to_owned(), root.clone()); | |
| self.catalog.push(CatalogEntry { | |
| manifest: manifest.clone(), | |
| root, | |
| }); | |
| results.push(Ok(manifest)); | |
| } | |
| } | |
| results | |
| } | |
| /// The manifests found by the last [`discover`](Self::discover). | |
| pub fn available(&self) -> impl Iterator<Item = &PluginManifest> { | |
| self.catalog.iter().map(|entry| &entry.manifest) | |
| } | |
| /// Evaluates a plugin's entry module and constructs its view. | |
| /// | |
| /// This is the only method that runs script. `authorize` is called with the | |
| /// inert manifest before its requested capabilities become a policy; a | |
| /// denial executes nothing. The whole approved load then runs inside that | |
| /// policy, because the entry module may use capabilities while registering. | |
| /// Call [`Self::discover`] first and handle every returned result; `load` | |
| /// never hides a malformed manifest behind a generic missing-id error. | |
| pub fn load( | |
| &mut self, | |
| runtime: &Rc<ShellRuntime>, | |
| id: &str, | |
| authorize: impl FnOnce(&PluginManifest) -> bool, | |
| window: &mut Window, | |
| cx: &mut App, | |
| ) -> Result<()> { | |
| if !self.discovered { | |
| bail!( | |
| "plugin discovery has not run; call PluginManager::discover() and handle every result before loading `{id}`" | |
| ); | |
| } | |
| if self.loaded.contains_key(id) { | |
| bail!("plugin `{id}` is already loaded; unload it first to load it again"); | |
| } | |
| let Some(entry) = self.catalog.iter().find(|entry| entry.manifest.id() == id) else { | |
| bail!("no plugin `{id}`{}", self.known_ids_hint()); | |
| }; | |
| let manifest = entry.manifest.clone(); | |
| if !authorize(&manifest) { | |
| bail!("capabilities for plugin `{id}` were not approved"); | |
| } | |
| let root = entry.root.clone(); | |
| let data_dir = self.data_dir(id); | |
| let plugin = load_plugin(runtime, manifest, root, data_dir, window, cx)?; | |
| self.loaded.insert(id.to_owned(), plugin); | |
| Ok(()) | |
| } | |
| /// Cancels work created under a plugin's policy, then drops its view and | |
| /// policy. Returns whether the plugin was loaded. | |
| /// | |
| /// There is no script `deactivate()` hook: host-owned task cancellation is | |
| /// deterministic and prevents owner-less work from retaining the unloaded | |
| /// plugin's authority. | |
| pub fn unload(&mut self, id: &str) -> bool { | |
| let Some(plugin) = self.loaded.remove(id) else { | |
| return false; | |
| }; | |
| plugin.shutdown(); | |
| true | |
| } | |
| pub fn loaded(&self) -> impl Iterator<Item = &Plugin> { | |
| self.loaded.values() | |
| } | |
| pub fn plugin(&self, id: &str) -> Option<&Plugin> { | |
| self.loaded.get(id) | |
| } | |
| /// Where a plugin's data lives. | |
| /// | |
| /// Keyed by `id`, not by path: an upgrade that replaces the plugin | |
| /// directory must not lose the user's data, and two checkouts of the same | |
| /// plugin are the same installation — which is the opposite of the rule for | |
| /// a directory run from the command line, where the path *is* the identity. | |
| pub fn data_dir(&self, id: &str) -> PathBuf { | |
| self.data_home.join("gpui-shell").join("plugins").join(id) | |
| } | |
| fn known_ids_hint(&self) -> String { | |
| let ids: Vec<&str> = self | |
| .catalog | |
| .iter() | |
| .map(|entry| entry.manifest.id()) | |
| .collect(); | |
| if ids.is_empty() { | |
| format!( | |
| "; no plugin was found in {}", | |
| self.directories | |
| .iter() | |
| .map(|directory| directory.display().to_string()) | |
| .collect::<Vec<_>>() | |
| .join(", ") | |
| ) | |
| } else { | |
| format!("; found {}", ids.join(", ")) | |
| } | |
| } | |
| } | |
| impl Drop for PluginManager { | |
| fn drop(&mut self) { | |
| for (_, plugin) in std::mem::take(&mut self.loaded) { | |
| plugin.shutdown(); | |
| } | |
| } | |
| } | |
| /// A configured directory is either one plugin or a directory of them. | |
| /// | |
| /// Both are worth supporting and they are distinguishable without guessing: a | |
| /// directory holding a manifest is a plugin, anything else is a container. That | |
| /// is what makes `--plugin ~/dev/my-plugin` work alongside a user's installed | |
| /// plugin folder, with no second flag to say which kind it is. | |
| fn plugin_roots(directory: &Path) -> Vec<PathBuf> { | |
| if directory.join(MANIFEST_FILE).is_file() { | |
| return vec![directory.to_path_buf()]; | |
| } | |
| let mut roots: Vec<PathBuf> = std::fs::read_dir(directory) | |
| .into_iter() | |
| .flatten() | |
| .flatten() | |
| .map(|entry| entry.path()) | |
| .filter(|path| path.join(MANIFEST_FILE).is_file()) | |
| .collect(); | |
| // Directory order is filesystem-dependent; a plugin list that reorders | |
| // itself between runs is a bug report waiting to happen. | |
| roots.sort(); | |
| roots | |
| } | |
| /// The platform's per-user data directory. | |
| /// | |
| /// Duplicated from `src/bin/gpui-shell.rs` on purpose rather than shared: this | |
| /// module owns no file but itself. It belongs in `runtime.rs` so the binary and | |
| /// the plugin manager cannot disagree about where a user's data is — see the | |
| /// report accompanying this module. | |
| fn default_data_home() -> PathBuf { | |
| if let Some(explicit) = std::env::var_os("XDG_DATA_HOME").filter(|it| !it.is_empty()) { | |
| return PathBuf::from(explicit); | |
| } | |
| let home = std::env::var_os("HOME") | |
| .or_else(|| std::env::var_os("USERPROFILE")) | |
| .map(PathBuf::from) | |
| .unwrap_or_else(std::env::temp_dir); | |
| if cfg!(target_os = "macos") { | |
| home.join("Library").join("Application Support") | |
| } else if cfg!(target_os = "windows") { | |
| std::env::var_os("APPDATA") | |
| .map(PathBuf::from) | |
| .unwrap_or_else(|| home.join("AppData").join("Roaming")) | |
| } else { | |
| home.join(".local").join("share") | |
| } | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| use gpui::{TestAppContext, VisualTestContext}; | |
| use std::ops::Deref as _; | |
| use std::sync::atomic::{AtomicU64, Ordering}; | |
| /// A complete manifest, requiring the runtime this test binary is. The | |
| /// crate version moves with every release and a `0.x` line accepts only | |
| /// its own minor, so a fixture that spelled a version out would expire at | |
| /// the next bump. | |
| const VALID: &str = concat!( | |
| r#"{ | |
| "id": "com.example.inbox", | |
| "name": "Inbox", | |
| "version": "1.2.0", | |
| "shell-version": ""#, | |
| env!("CARGO_PKG_VERSION"), | |
| r#"", | |
| "entry": "main.js", | |
| "capabilities": { | |
| "fs": { | |
| "read": ["${pluginDir}", "${dataDir}"], | |
| "write": ["${dataDir}"], | |
| "execute": ["git"] | |
| }, | |
| "network": { | |
| "hosts": ["api.example.com"], | |
| "http": [{ | |
| "host": "readonly.example.com", | |
| "methods": ["GET"], | |
| "paths": ["/v1/account"], | |
| "path_prefixes": ["/v1/quotes/"] | |
| }] | |
| }, | |
| "storage": true, | |
| "clipboard": { "write": true }, | |
| "process": { "exit": true } | |
| } | |
| }"# | |
| ); | |
| /// A directory that removes itself. `tempfile` is not a dependency of this | |
| /// crate and one test module is not a reason to add one. | |
| struct TempTree(PathBuf); | |
| impl TempTree { | |
| fn new(label: &str) -> Self { | |
| static COUNTER: AtomicU64 = AtomicU64::new(0); | |
| let unique = COUNTER.fetch_add(1, Ordering::Relaxed); | |
| let path = std::env::temp_dir().join(format!( | |
| "gpui-shell-plugin-{label}-{}-{unique}", | |
| std::process::id() | |
| )); | |
| let _ = std::fs::remove_dir_all(&path); | |
| std::fs::create_dir_all(&path).expect("cannot create a temporary directory"); | |
| Self(path) | |
| } | |
| fn plugin(&self, directory: &str, manifest: &str) -> PathBuf { | |
| let root = self.0.join(directory); | |
| std::fs::create_dir_all(&root).expect("cannot create a plugin directory"); | |
| std::fs::write(root.join(MANIFEST_FILE), manifest).expect("cannot write a manifest"); | |
| root | |
| } | |
| fn path(&self) -> &Path { | |
| &self.0 | |
| } | |
| } | |
| impl Drop for TempTree { | |
| fn drop(&mut self) { | |
| let _ = std::fs::remove_dir_all(&self.0); | |
| } | |
| } | |
| #[gpui::test] | |
| fn default_runtime_global_does_not_retain_the_runtime(cx: &mut TestAppContext) { | |
| cx.update(crate::init); | |
| let runtime = cx | |
| .update(ShellRuntime::new) | |
| .expect("default application runtime"); | |
| let weak = Rc::downgrade(&runtime); | |
| drop(runtime); | |
| assert!( | |
| weak.upgrade().is_none(), | |
| "the App global must not keep an otherwise unowned runtime alive" | |
| ); | |
| assert!(cx.update(|cx| ShellRuntime::global(cx)).is_none()); | |
| let replacement = cx | |
| .update(ShellRuntime::new) | |
| .expect("a dead default runtime can be replaced"); | |
| let installed = cx.update(|cx| ShellRuntime::global(cx).expect("replacement runtime")); | |
| assert!(Rc::ptr_eq(&replacement, &installed)); | |
| } | |
| #[gpui::test] | |
| fn runtime_load_builds_the_window_root_without_plugin_manager_ceremony( | |
| cx: &mut TestAppContext, | |
| ) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("direct-load"); | |
| std::fs::write( | |
| application.path().join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| export default class App extends View { | |
| render(cx) { return "loaded"; } | |
| } | |
| "#, | |
| ) | |
| .expect("application source"); | |
| let runtime = cx | |
| .update(ShellRuntime::new) | |
| .expect("default application runtime"); | |
| let installed = cx.update(|cx| ShellRuntime::global(cx).expect("installed runtime")); | |
| assert!(Rc::ptr_eq(&runtime, &installed)); | |
| let duplicate = cx.update(ShellRuntime::new); | |
| assert!( | |
| duplicate | |
| .as_ref() | |
| .is_err_and(|error| error.to_string().contains("new_isolated")), | |
| "constructing a second default runtime must not silently replace the first" | |
| ); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let root = context.update(|window, cx| runtime.load(application.path(), window, cx)); | |
| context.update(|_, cx| { | |
| assert_eq!(root.read(cx).dialog_count(), 0); | |
| assert!( | |
| root.read(cx) | |
| .content() | |
| .clone() | |
| .downcast::<ScriptView>() | |
| .is_ok() | |
| ); | |
| }); | |
| let watch = context | |
| .update(|window, cx| runtime.watch(&root, window, cx)) | |
| .expect("loaded root retains its source metadata"); | |
| drop(watch); | |
| context | |
| .update(|_, cx| runtime.refresh(&root, cx)) | |
| .expect("loaded root can be refreshed without exposing ScriptView"); | |
| let other_runtime = ShellRuntime::new_isolated().expect("other runtime"); | |
| let error = context | |
| .update(|_, cx| other_runtime.refresh(&root, cx)) | |
| .expect_err("a runtime must not refresh another runtime's application"); | |
| assert!(error.to_string().contains("different gpui-shell runtime")); | |
| } | |
| #[gpui::test] | |
| fn runtime_try_load_preserves_the_structured_script_error(cx: &mut TestAppContext) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("direct-try-load-error"); | |
| std::fs::write(application.path().join("main.js"), "this is not javascript") | |
| .expect("broken source"); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let error = context | |
| .update(|window, cx| runtime.try_load(application.path(), window, cx)) | |
| .expect_err("try_load must not replace a structured error with a view"); | |
| assert!( | |
| error.to_string().contains("main.js"), | |
| "the source path must survive: {error:#}" | |
| ); | |
| } | |
| #[gpui::test] | |
| fn runtime_check_returns_the_rendered_description(cx: &mut TestAppContext) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("direct-check"); | |
| std::fs::write( | |
| application.path().join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| import { Button } from "gpui-base"; | |
| export default class App extends View { | |
| render(cx) { | |
| return Button.new("checked").child("checked through the facade"); | |
| } | |
| } | |
| "#, | |
| ) | |
| .expect("application source"); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let description = context | |
| .update(|window, cx| runtime.check(application.path(), window, cx)) | |
| .expect("check application through the high-level facade"); | |
| assert!(description.contains("checked through the facade")); | |
| let metrics = runtime.metrics().read(); | |
| assert_eq!(metrics.script_renders(), 1); | |
| assert_eq!(metrics.materializations(), 1); | |
| } | |
| #[gpui::test] | |
| fn runtime_check_materializes_text_views_without_drawing(cx: &mut TestAppContext) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("check-text-views"); | |
| std::fs::write( | |
| application.path().join("main.js"), | |
| r##" | |
| import { div, View } from "gpui-kit"; | |
| import { TextView } from "gpui-base"; | |
| export default class App extends View { | |
| render() { | |
| return div() | |
| .child(TextView.markdown("markdown", "# Markdown")) | |
| .child(TextView.html("html", "<p>HTML</p>")); | |
| } | |
| } | |
| "##, | |
| ) | |
| .expect("application source"); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| // CLI check only constructs elements; no layout or drawing is active. | |
| let description = context | |
| .update(|window, cx| runtime.check(application.path(), window, cx)) | |
| .expect("check TextViews outside the drawing lifecycle"); | |
| assert!(description.contains("# Markdown")); | |
| assert!(description.contains("<p>HTML</p>")); | |
| } | |
| #[gpui::test] | |
| fn runtime_load_uses_the_manifest_entry_without_plugin_manager_ceremony( | |
| cx: &mut TestAppContext, | |
| ) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("direct-manifest-load"); | |
| std::fs::write( | |
| application.path().join(MANIFEST_FILE), | |
| r#"{ | |
| "id": "com.example.direct-load", | |
| "name": "Direct load", | |
| "entry": "application.js", | |
| "capabilities": { | |
| "network": { "hosts": ["api.example.com"] }, | |
| "process": { "exit": true } | |
| } | |
| }"#, | |
| ) | |
| .expect("application manifest"); | |
| std::fs::write( | |
| application.path().join("application.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| export default class App extends View { | |
| render() { return "manifest entry loaded"; } | |
| } | |
| "#, | |
| ) | |
| .expect("application source"); | |
| let runtime = cx | |
| .update(ShellRuntime::new) | |
| .expect("default application runtime"); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let root = context.update(|window, cx| runtime.load(application.path(), window, cx)); | |
| let policy = context.update(|_, cx| { | |
| let view = root | |
| .read(cx) | |
| .content() | |
| .clone() | |
| .downcast::<ScriptView>() | |
| .expect("manifest entry view"); | |
| view.read(cx).policy() | |
| }); | |
| assert!( | |
| !Rc::ptr_eq(&policy, &crate::policy::default()), | |
| "each loaded application needs its own task-cancellation identity" | |
| ); | |
| assert!( | |
| !policy.capabilities().may_exit(), | |
| "a manifest requests capabilities; runtime.load must not approve them" | |
| ); | |
| assert!( | |
| !policy.capabilities().may_reach("api.example.com"), | |
| "the host's default policy remains the permission ceiling" | |
| ); | |
| } | |
| #[gpui::test] | |
| fn runtime_load_cancels_tasks_when_initialization_fails(cx: &mut TestAppContext) { | |
| cx.update(crate::init); | |
| let application = TempTree::new("failed-load-tasks"); | |
| std::fs::write( | |
| application.path().join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| export default class Broken extends View { | |
| init(_props, cx) { | |
| cx.timer.every(1_000, () => {}); | |
| throw new Error("initialization failed"); | |
| } | |
| render(cx) { throw new Error("unreachable"); } | |
| } | |
| "#, | |
| ) | |
| .expect("application source"); | |
| let runtime = cx | |
| .update(ShellRuntime::new) | |
| .expect("default application runtime"); | |
| let tasks_before = crate::engine::quickjs::task_count(); | |
| let window = cx.add_window(|_, _| gpui::Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let root = context.update(|window, cx| runtime.load(application.path(), window, cx)); | |
| context.update(|_, cx| { | |
| assert!( | |
| root.read(cx) | |
| .content() | |
| .clone() | |
| .downcast::<ScriptView>() | |
| .is_err(), | |
| "a failed application must render the failure surface" | |
| ); | |
| }); | |
| assert_eq!( | |
| crate::engine::quickjs::task_count(), | |
| tasks_before, | |
| "tasks started before initialization failed must be cancelled" | |
| ); | |
| } | |
| #[gpui::test] | |
| fn async_init_runs_under_the_plugin_policy_and_notifies_its_view(cx: &mut TestAppContext) { | |
| let plugins = TempTree::new("async-init"); | |
| let data = TempTree::new("async-init-data"); | |
| let manifest = r#"{ | |
| "id": "com.example.async-init", | |
| "name": "Async Init", | |
| "version": "1.0.0", | |
| "entry": "main.js", | |
| "capabilities": { | |
| "fs": { "read": ["${pluginDir}"] } | |
| } | |
| }"#; | |
| let root = plugins.plugin("async-init", manifest); | |
| std::fs::write(root.join("message.txt"), "loaded through plugin policy") | |
| .expect("write fixture"); | |
| std::fs::write( | |
| root.join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| import { v_flex } from "gpui-base"; | |
| import * as fs from "fs/promises"; | |
| export default class Panel extends View { | |
| init(_props, cx) { | |
| this.message = "pending"; | |
| cx.spawn(async (cx) => { | |
| this.message = await fs.readFile("message.txt", "utf8"); | |
| cx.notify(); | |
| }); | |
| } | |
| render(cx) { return v_flex().child(this.message); } | |
| } | |
| "#, | |
| ) | |
| .expect("write script"); | |
| cx.update(crate::init); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| cx.update(|cx| runtime.set_global(cx)); | |
| let window = cx.add_window(|_, _| Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let mut manager = PluginManager::new(vec![plugins.path().to_path_buf()]) | |
| .with_data_home(data.path().to_path_buf()); | |
| let undiscovered = context | |
| .update(|window, cx| { | |
| manager.load(&runtime, "com.example.async-init", |_| true, window, cx) | |
| }) | |
| .expect_err("load must not hide discovery errors"); | |
| assert!( | |
| undiscovered.to_string().contains("discover"), | |
| "{undiscovered:#}" | |
| ); | |
| let discovered = manager.discover(); | |
| assert!( | |
| discovered.iter().all(Result::is_ok), | |
| "test plugin must discover cleanly: {discovered:?}" | |
| ); | |
| let denied = context | |
| .update(|window, cx| { | |
| manager.load(&runtime, "com.example.async-init", |_| false, window, cx) | |
| }) | |
| .expect_err("an unapproved manifest must not execute"); | |
| assert!(denied.to_string().contains("not approved"), "{denied:#}"); | |
| assert_eq!(manager.loaded().count(), 0); | |
| context | |
| .update(|window, cx| { | |
| manager.load(&runtime, "com.example.async-init", |_| true, window, cx) | |
| }) | |
| .expect("load plugin"); | |
| let view = manager | |
| .plugin("com.example.async-init") | |
| .map(Plugin::view) | |
| .expect("plugin view") | |
| .clone(); | |
| draw(&mut context, &view); | |
| assert!(snapshot_text(&mut context, &view).contains("pending")); | |
| context.run_until_parked(); | |
| draw(&mut context, &view); | |
| let settled = snapshot_text(&mut context, &view); | |
| assert!( | |
| settled.contains("loaded through plugin policy"), | |
| "async init did not invalidate its own view: {settled}" | |
| ); | |
| } | |
| #[gpui::test] | |
| fn unloading_a_plugin_releases_its_entities_and_subscriptions(cx: &mut TestAppContext) { | |
| let plugins = TempTree::new("unload-entities"); | |
| let data = TempTree::new("unload-entities-data"); | |
| let manifest = r#"{ | |
| "id": "com.example.retained-input", | |
| "name": "Retained input", | |
| "entry": "main.js" | |
| }"#; | |
| let root = plugins.plugin("retained-input", manifest); | |
| std::fs::write( | |
| root.join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| import { Input, InputState } from "gpui-base"; | |
| export default class Panel extends View { | |
| init() { | |
| this.field = InputState.new({ value: "owned by plugin" }); | |
| this.field.on("change", () => {}); | |
| } | |
| render(cx) { return Input.new(this.field); } | |
| } | |
| "#, | |
| ) | |
| .expect("write script"); | |
| cx.update(crate::init); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| cx.update(|cx| runtime.set_global(cx)); | |
| let window = cx.add_window(|_, _| Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let mut manager = PluginManager::new(vec![plugins.path().to_path_buf()]) | |
| .with_data_home(data.path().to_path_buf()); | |
| assert!(manager.discover().iter().all(Result::is_ok)); | |
| context | |
| .update(|window, cx| { | |
| manager.load(&runtime, "com.example.retained-input", |_| true, window, cx) | |
| }) | |
| .expect("load plugin"); | |
| assert_eq!(runtime.entities().len(), 1); | |
| assert!(manager.unload("com.example.retained-input")); | |
| assert!( | |
| runtime.entities().is_empty(), | |
| "unload must drop the input record and its GPUI subscriptions" | |
| ); | |
| } | |
| #[gpui::test] | |
| fn dropping_plugin_manager_shuts_down_loaded_plugins(cx: &mut TestAppContext) { | |
| let plugins = TempTree::new("drop-manager"); | |
| let data = TempTree::new("drop-manager-data"); | |
| let manifest = r#"{ | |
| "id": "com.example.drop-manager", | |
| "name": "Drop manager", | |
| "entry": "main.js" | |
| }"#; | |
| let root = plugins.plugin("drop-manager", manifest); | |
| std::fs::write( | |
| root.join("main.js"), | |
| r#" | |
| import { div, View } from "gpui-kit"; | |
| import { Input, InputState } from "gpui-base"; | |
| export default class Panel extends View { | |
| init(_props, cx) { | |
| this.field = InputState.new({ value: "owned by plugin" }); | |
| cx.timer.every(60_000, () => {}); | |
| } | |
| render(cx) { return Input.new(this.field); } | |
| } | |
| "#, | |
| ) | |
| .expect("write script"); | |
| cx.update(crate::init); | |
| let runtime = ShellRuntime::new_isolated().expect("runtime"); | |
| cx.update(|cx| runtime.set_global(cx)); | |
| let tasks_before = crate::engine::quickjs::task_count(); | |
| let window = cx.add_window(|_, _| Empty); | |
| let mut context = VisualTestContext::from_window(*window.deref(), cx); | |
| let mut manager = PluginManager::new(vec![plugins.path().to_path_buf()]) | |
| .with_data_home(data.path().to_path_buf()); | |
| assert!(manager.discover().iter().all(Result::is_ok)); | |
| context | |
| .update(|window, cx| { | |
| manager.load(&runtime, "com.example.drop-manager", |_| true, window, cx) | |
| }) | |
| .expect("load plugin"); | |
| let retained_view = manager | |
| .plugin("com.example.drop-manager") | |
| .expect("loaded plugin") | |
| .view() | |
| .clone(); | |
| assert_eq!(runtime.entities().len(), 1); | |
| assert_eq!(crate::engine::quickjs::task_count(), tasks_before + 1); | |
| drop(manager); | |
| assert!( | |
| runtime.entities().is_empty(), | |
| "dropping the manager must release application-owned entities even while GPUI retains the view" | |
| ); | |
| assert_eq!( | |
| crate::engine::quickjs::task_count(), | |
| tasks_before, | |
| "dropping the manager must cancel application-owned tasks" | |
| ); | |
| drop(retained_view); | |
| } | |
| fn draw(context: &mut VisualTestContext, view: &Entity<ScriptView>) { | |
| let view = view.clone(); | |
| context.draw( | |
| gpui::Point::default(), | |
| gpui::size(gpui::px(400.), gpui::px(300.)), | |
| move |_, _| view.into_any_element(), | |
| ); | |
| } | |
| fn snapshot_text(context: &mut VisualTestContext, view: &Entity<ScriptView>) -> String { | |
| context.update(|_, cx| { | |
| view.read(cx) | |
| .snapshot() | |
| .map(crate::RenderSnapshot::debug_tree) | |
| .unwrap_or_default() | |
| }) | |
| } | |
| struct Empty; | |
| impl gpui::Render for Empty { | |
| fn render( | |
| &mut self, | |
| _: &mut gpui::Window, | |
| _: &mut gpui::Context<Self>, | |
| ) -> impl gpui::IntoElement { | |
| gpui::div() | |
| } | |
| } | |
| #[test] | |
| fn a_valid_manifest_reads_back_what_was_written() { | |
| let manifest = PluginManifest::parse(VALID).expect("the manifest should parse"); | |
| assert_eq!(manifest.id(), "com.example.inbox"); | |
| assert_eq!(manifest.name(), "Inbox"); | |
| assert_eq!(manifest.version(), "1.2.0"); | |
| assert_eq!(manifest.shell_version(), SHELL_VERSION); | |
| assert_eq!(manifest.entry(), "main.js"); | |
| let capabilities = | |
| manifest.capabilities(Path::new("/plugins/inbox"), Path::new("/data/inbox")); | |
| assert!(capabilities.has_storage()); | |
| assert!(capabilities.is_clipboard_writable()); | |
| assert!(!capabilities.is_clipboard_readable()); | |
| assert!(capabilities.may_exit()); | |
| } | |
| #[test] | |
| fn a_manifest_accepts_the_omarchy_ui_https_remote_without_dot_git() { | |
| let source = r#"{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "https://github.com/huacnlee/omarchy-ui", | |
| "branch": "main" | |
| } | |
| } | |
| }"#; | |
| let manifest = | |
| PluginManifest::parse(source).expect("a branch-pinned Git dependency should parse"); | |
| let dependency = &manifest.dependencies()["omarchy-ui"]; | |
| assert_eq!(dependency.git(), "https://github.com/huacnlee/omarchy-ui"); | |
| assert_eq!(dependency.branch(), Some("main")); | |
| assert_eq!(dependency.tag(), None); | |
| assert_eq!(dependency.entry(), "index.js"); | |
| } | |
| #[test] | |
| fn a_manifest_accepts_git_dependency_strings() { | |
| for (source, expected_git, expected_reference) in [ | |
| ( | |
| "https://github.com/huacnlee/omarchy-ui#main", | |
| "https://github.com/huacnlee/omarchy-ui", | |
| Some("main"), | |
| ), | |
| ( | |
| "https://github.com/huacnlee/omarchy-ui#v1.2.0", | |
| "https://github.com/huacnlee/omarchy-ui", | |
| Some("v1.2.0"), | |
| ), | |
| ( | |
| "https://github.com/huacnlee/omarchy-ui#0123456789abcdef0123456789abcdef01234567", | |
| "https://github.com/huacnlee/omarchy-ui", | |
| Some("0123456789abcdef0123456789abcdef01234567"), | |
| ), | |
| ( | |
| "https://github.com/huacnlee/omarchy-ui", | |
| "https://github.com/huacnlee/omarchy-ui", | |
| None, | |
| ), | |
| ] { | |
| let manifest = PluginManifest::parse(&format!( | |
| r#"{{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": {{ "omarchy-ui": {} }} | |
| }}"#, | |
| serde_json::to_string(source).expect("dependency as JSON") | |
| )) | |
| .expect("a Git dependency string should parse"); | |
| let dependency = &manifest.dependencies()["omarchy-ui"]; | |
| assert_eq!(dependency.git(), expected_git); | |
| assert_eq!(dependency.reference(), expected_reference); | |
| assert!(dependency.uses_package_entry()); | |
| } | |
| } | |
| #[test] | |
| fn github_shorthand_expands_to_an_https_remote_and_defaults_to_main() { | |
| for (source, expected_reference) in [ | |
| ("huacnlee/omarchy-ui", "main"), | |
| ("huacnlee/omarchy-ui#stable", "stable"), | |
| ] { | |
| let manifest = PluginManifest::parse(&format!( | |
| r#"{{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": {{ "omarchy-ui": {} }} | |
| }}"#, | |
| serde_json::to_string(source).expect("dependency as JSON") | |
| )) | |
| .expect("strict GitHub shorthand should parse"); | |
| let dependency = &manifest.dependencies()["omarchy-ui"]; | |
| assert_eq!(dependency.git(), "https://github.com/huacnlee/omarchy-ui"); | |
| assert_eq!(dependency.reference(), Some(expected_reference)); | |
| assert!(dependency.uses_package_entry()); | |
| } | |
| } | |
| #[test] | |
| fn malformed_or_ambiguous_git_dependency_strings_are_rejected() { | |
| for source in [ | |
| "huacnlee", | |
| "/omarchy-ui", | |
| "huacnlee/", | |
| "huacnlee/omarchy-ui/extra", | |
| "huacnlee//omarchy-ui", | |
| "huacnlee/omarchy-ui#", | |
| "https://github.com/huacnlee/omarchy-ui#", | |
| "not a git dependency", | |
| ] { | |
| let error = PluginManifest::parse(&format!( | |
| r#"{{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": {{ "omarchy-ui": {} }} | |
| }}"#, | |
| serde_json::to_string(source).expect("dependency as JSON") | |
| )) | |
| .expect_err("malformed string syntax must not select an unintended repository"); | |
| assert!( | |
| error.to_string().contains("Git URL") | |
| || error.to_string().contains("owner/repository") | |
| || error.to_string().contains("Git ref"), | |
| "`{source}` produced an unclear error: {error:#}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn object_git_dependencies_keep_their_explicit_selector_and_entry_contract() { | |
| let source = r#"{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "https://github.com/huacnlee/omarchy-ui", | |
| "tag": "v1.2.0", | |
| "entry": "src/public.js" | |
| } | |
| } | |
| }"#; | |
| let manifest = PluginManifest::parse(source).expect("the legacy object form should parse"); | |
| let dependency = &manifest.dependencies()["omarchy-ui"]; | |
| assert_eq!(dependency.branch(), None); | |
| assert_eq!(dependency.tag(), Some("v1.2.0")); | |
| assert_eq!(dependency.entry(), "src/public.js"); | |
| assert!(!dependency.uses_package_entry()); | |
| } | |
| #[test] | |
| fn a_manifest_accepts_a_git_dependency_pinned_to_a_tag() { | |
| let source = r#"{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "ssh://git@example.com/omarchy-ui.git", | |
| "tag": "v1.2.0", | |
| "entry": "src/public.js" | |
| } | |
| } | |
| }"#; | |
| let manifest = | |
| PluginManifest::parse(source).expect("a tag-pinned Git dependency should parse"); | |
| let dependency = &manifest.dependencies()["omarchy-ui"]; | |
| assert_eq!(dependency.branch(), None); | |
| assert_eq!(dependency.tag(), Some("v1.2.0")); | |
| assert_eq!(dependency.entry(), "src/public.js"); | |
| } | |
| #[test] | |
| fn a_git_dependency_cannot_select_a_branch_and_a_tag() { | |
| let source = r#"{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "https://github.com/example/omarchy-ui.git", | |
| "branch": "main", | |
| "tag": "v1.2.0" | |
| } | |
| } | |
| }"#; | |
| let error = PluginManifest::parse(source).expect_err("the ref is ambiguous"); | |
| assert!(error.to_string().contains("either `branch` or `tag`")); | |
| } | |
| #[test] | |
| fn a_git_dependency_entry_cannot_escape_its_checkout() { | |
| let source = r#"{ | |
| "id": "com.example.third-party-ui", | |
| "name": "Third-party UI", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "https://github.com/example/omarchy-ui.git", | |
| "tag": "v1.2.0", | |
| "entry": "../private.js" | |
| } | |
| } | |
| }"#; | |
| let error = PluginManifest::parse(source).expect_err("the entry escapes the checkout"); | |
| assert!(error.to_string().contains("path inside the Git repository")); | |
| } | |
| #[test] | |
| fn a_git_dependency_cannot_use_a_runtime_module_name() { | |
| let source = r#"{ | |
| "id": "com.example.shadow", | |
| "name": "Shadow", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "gpui-kit": { | |
| "git": "https://github.com/example/not-gpui.git", | |
| "branch": "main" | |
| } | |
| } | |
| }"#; | |
| let error = PluginManifest::parse(source).expect_err("the dependency is unreachable"); | |
| assert!(error.to_string().contains("reserved by gpui-shell")); | |
| } | |
| #[test] | |
| fn a_git_dependency_rejects_refspec_syntax_in_a_branch() { | |
| let source = r#"{ | |
| "id": "com.example.refspec", | |
| "name": "Refspec", | |
| "entry": "main.js", | |
| "dependencies": { | |
| "omarchy-ui": { | |
| "git": "https://github.com/example/omarchy-ui.git", | |
| "branch": "main:refs/heads/injected" | |
| } | |
| } | |
| }"#; | |
| let error = PluginManifest::parse(source).expect_err("a selector is not a refspec"); | |
| assert!(error.to_string().contains("valid Git ref name")); | |
| } | |
| #[test] | |
| fn shell_version_is_declared_in_the_manifest_before_script_runs() { | |
| let manifest = PluginManifest::parse(VALID) | |
| .expect("the runtime version belongs in inert manifest metadata"); | |
| assert_eq!(manifest.shell_version(), SHELL_VERSION); | |
| } | |
| #[test] | |
| fn an_omitted_shell_version_accepts_the_current_runtime() { | |
| let source = VALID.replace( | |
| &format!(" \"shell-version\": \"{SHELL_VERSION}\",\n"), | |
| "", | |
| ); | |
| let manifest = PluginManifest::parse(&source) | |
| .expect("omitting shell-version supports the runtime loading the application"); | |
| assert_eq!(manifest.shell_version(), SHELL_VERSION); | |
| } | |
| #[test] | |
| fn the_window_size_is_read_from_the_manifest() { | |
| let manifest = PluginManifest::parse( | |
| r#"{"id": "com.example.calc", "name": "Calc", "entry": "main.js", | |
| "window": {"width": 360, "height": 520}}"#, | |
| ) | |
| .expect("a window size is a valid manifest field"); | |
| let window = manifest.window().expect("the size is kept"); | |
| assert_eq!((window.width(), window.height()), (360., 520.)); | |
| let manifest = PluginManifest::parse( | |
| r#"{"id": "com.example.calc", "name": "Calc", "entry": "main.js"}"#, | |
| ) | |
| .expect("the window is optional"); | |
| assert_eq!(manifest.window(), None); | |
| } | |
| #[test] | |
| fn invalid_window_sizes_are_rejected() { | |
| for window in [ | |
| r#"{"width": 0, "height": 520}"#, | |
| r#"{"width": 360, "height": -1}"#, | |
| r#"{"width": 360}"#, | |
| r#"{"width": 360, "height": 520, "x": 0}"#, | |
| r#""360x520""#, | |
| ] { | |
| let source = format!( | |
| r#"{{"id": "com.example.calc", "name": "Calc", "entry": "main.js", "window": {window}}}"# | |
| ); | |
| let error = PluginManifest::parse(&source).expect_err(window); | |
| assert!( | |
| error.to_string().contains("invalid `window`"), | |
| "{window}: {error}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn an_omitted_application_version_is_reported_as_unknown() { | |
| let source = VALID.replace(" \"version\": \"1.2.0\",\n", ""); | |
| let manifest = PluginManifest::parse(&source).expect("version metadata is optional"); | |
| assert_eq!(manifest.version(), "unknown"); | |
| } | |
| #[test] | |
| fn capabilities_become_a_real_grant() { | |
| let manifest = PluginManifest::parse(VALID).expect("the manifest should parse"); | |
| // Real directories, because a grant is an open handle now: the | |
| // placeholders still resolve to paths, but a path is only half of one. | |
| let base = std::env::temp_dir().join(format!("gpui-shell-grant-{}", std::process::id())); | |
| let _ = std::fs::remove_dir_all(&base); | |
| let plugin_dir = base.join("plugins/inbox"); | |
| let data_dir = base.join("data/inbox"); | |
| std::fs::create_dir_all(&plugin_dir).expect("a plugin directory"); | |
| std::fs::create_dir_all(&data_dir).expect("a data directory"); | |
| let capabilities = manifest.capabilities(&plugin_dir, &data_dir); | |
| assert_eq!( | |
| capabilities.execute_grant(), | |
| &ExecuteGrant::Allowed(vec!["git".to_owned()]) | |
| ); | |
| assert!(capabilities.may_run("git")); | |
| assert!(!capabilities.may_run("curl")); | |
| assert!(capabilities.may_reach("api.example.com")); | |
| assert!(!capabilities.may_reach("evil.example.com")); | |
| assert!(capabilities.may_request( | |
| "https", | |
| "readonly.example.com", | |
| None, | |
| "GET", | |
| "/v1/account" | |
| )); | |
| assert!(capabilities.may_request( | |
| "https", | |
| "readonly.example.com", | |
| None, | |
| "GET", | |
| "/v1/quotes/AAPL.US" | |
| )); | |
| assert!(!capabilities.may_request( | |
| "https", | |
| "readonly.example.com", | |
| None, | |
| "POST", | |
| "/v1/account" | |
| )); | |
| // The placeholders are the only way a manifest can name a directory it | |
| // does not know the path of. | |
| assert_eq!( | |
| capabilities | |
| .open(Path::new("main.js"), crate::capability::Access::Read) | |
| .expect("the plugin directory should be readable") | |
| .path(), | |
| Path::new("main.js") | |
| ); | |
| assert_eq!( | |
| capabilities | |
| .open( | |
| &data_dir.join("items.json"), | |
| crate::capability::Access::Write | |
| ) | |
| .expect("the data directory should be writable") | |
| .path(), | |
| Path::new("items.json") | |
| ); | |
| assert!( | |
| capabilities | |
| .open(Path::new("/etc/passwd"), crate::capability::Access::Read) | |
| .is_err() | |
| ); | |
| let _ = std::fs::remove_dir_all(&base); | |
| } | |
| /// Everything that reaches outside the application is denied; its own | |
| /// storage is not, on the same reasoning a browser gives every origin a | |
| /// `localStorage` without asking. | |
| #[test] | |
| fn an_absent_capabilities_block_grants_nothing_but_storage() { | |
| let manifest = PluginManifest::parse( | |
| r#"{"id": "a.b", "name": "B", "version": "0.1.0", "entry": "main.js"}"#, | |
| ) | |
| .expect("capabilities may be omitted"); | |
| let capabilities = manifest.capabilities(Path::new("/plugins/b"), Path::new("/data/b")); | |
| assert!(capabilities.has_storage()); | |
| assert!(!capabilities.has_read_access()); | |
| assert!(!capabilities.has_write_access()); | |
| assert_eq!(capabilities.execute_grant(), &ExecuteGrant::Denied); | |
| } | |
| /// The point of keeping storage a capability rather than making it ambient | |
| /// like `sessionStorage`: a host running code it does not trust can still | |
| /// refuse, and refusing has to actually work. | |
| #[test] | |
| fn a_manifest_may_still_refuse_storage() { | |
| let manifest = PluginManifest::parse( | |
| r#"{"id": "a.b", "name": "B", "version": "0.1.0", | |
| "entry": "main.js", "capabilities": {"storage": false}}"#, | |
| ) | |
| .expect("storage may be declined"); | |
| assert!( | |
| !manifest | |
| .capabilities(Path::new("/plugins/b"), Path::new("/data/b")) | |
| .has_storage() | |
| ); | |
| } | |
| /// The trap this default exists to close: declaring one unrelated grant | |
| /// must not silently cost an application its settings. | |
| #[test] | |
| fn declaring_another_grant_does_not_cost_an_application_its_storage() { | |
| let manifest = PluginManifest::parse( | |
| r#"{"id": "a.b", "name": "B", "version": "0.1.0", | |
| "entry": "main.js", "capabilities": {"network": {"hosts": ["example.com"]}}}"#, | |
| ) | |
| .expect("a network grant is legal on its own"); | |
| assert!( | |
| manifest | |
| .capabilities(Path::new("/plugins/b"), Path::new("/data/b")) | |
| .has_storage() | |
| ); | |
| } | |
| #[test] | |
| fn each_missing_field_names_itself() { | |
| for field in ["id", "name", "entry"] { | |
| let mut fields: serde_json::Map<String, serde_json::Value> = | |
| serde_json::from_str(VALID).expect("the fixture should be an object"); | |
| fields.remove(field); | |
| let source = serde_json::to_string(&fields).expect("re-encoding cannot fail"); | |
| let error = PluginManifest::parse(&source).expect_err("the field is required"); | |
| assert_eq!(error.problem(), &ManifestProblem::MissingField(field)); | |
| let message = error.to_string(); | |
| assert!( | |
| message.contains(&format!("`{field}`")), | |
| "the message must name the field: {message}" | |
| ); | |
| assert!( | |
| message.contains("expected"), | |
| "the message must say what was expected: {message}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn a_field_of_the_wrong_type_says_which_and_what() { | |
| let error = PluginManifest::parse( | |
| r#"{"id": "a.b", "name": 7, "version": "0.1.0", "entry": "main.js"}"#, | |
| ) | |
| .expect_err("a numeric name is not a name"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::WrongType { | |
| field: "name", | |
| found: "a number" | |
| } | |
| ); | |
| assert!(error.to_string().contains("must be a string")); | |
| } | |
| #[test] | |
| fn a_typo_in_capabilities_is_refused_rather_than_granting_nothing() { | |
| let source = VALID.replacen("\"capabilities\"", "\"capabilites\"", 1); | |
| let error = PluginManifest::parse(&source).expect_err("an unknown field is not accepted"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::UnknownField { | |
| field: "capabilites".to_owned(), | |
| suggestion: Some("capabilities"), | |
| } | |
| ); | |
| let message = error.to_string(); | |
| assert!( | |
| message.contains("did you mean `capabilities`?"), | |
| "{message}" | |
| ); | |
| } | |
| #[test] | |
| fn a_typo_inside_the_capabilities_block_is_refused_too() { | |
| let source = VALID.replacen("\"network\"", "\"netwrok\"", 1); | |
| let error = PluginManifest::parse(&source).expect_err("an unknown grant is not accepted"); | |
| let message = error.to_string(); | |
| assert!(message.contains("netwrok"), "{message}"); | |
| assert!(message.contains("invalid `capabilities`"), "{message}"); | |
| } | |
| #[test] | |
| fn malformed_http_grants_are_rejected_at_manifest_parse_time() { | |
| for (needle, replacement, expected) in [ | |
| ( | |
| "api.example.com", | |
| "https://api.example.com", | |
| "without a scheme", | |
| ), | |
| ("GET", "GETT", "invalid HTTP method"), | |
| ("/v1/account", "v1/account", "must start with `/`"), | |
| ] { | |
| let source = VALID.replacen(needle, replacement, 1); | |
| let error = PluginManifest::parse(&source).expect_err("invalid grant must fail fast"); | |
| assert!(error.to_string().contains(expected), "{error}"); | |
| } | |
| let source = VALID.replacen( | |
| "\"host\": \"readonly.example.com\"", | |
| "\"scheme\": \"ftp\", \"host\": \"readonly.example.com\"", | |
| 1, | |
| ); | |
| let error = PluginManifest::parse(&source).expect_err("invalid scheme must fail fast"); | |
| assert!(error.to_string().contains("must be `http` or `https`")); | |
| } | |
| #[test] | |
| fn a_contribution_declared_in_the_manifest_is_refused() { | |
| // The rule the manifest exists to hold: contributions live in script. | |
| let source = VALID.replacen("\"name\"", "\"contributes\"", 1); | |
| let error = PluginManifest::parse(&source).expect_err("`contributes` is not a field"); | |
| let message = error.to_string(); | |
| assert!(message.contains("registered in script"), "{message}"); | |
| } | |
| #[test] | |
| fn an_id_that_could_leave_its_data_directory_is_refused() { | |
| for id in [ | |
| "../escape", | |
| "com.Example.Inbox", | |
| "a/b", | |
| "..", | |
| ".hidden", | |
| "inbox.", | |
| ] { | |
| let source = VALID.replacen("com.example.inbox", id, 1); | |
| assert!( | |
| PluginManifest::parse(&source).is_err(), | |
| "`{id}` must be refused: it becomes a directory name and a panel prefix" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn an_invalid_id_explains_the_rule() { | |
| let source = VALID.replacen("com.example.inbox", "../escape", 1); | |
| let error = PluginManifest::parse(&source).expect_err("an id may not contain a separator"); | |
| assert!(matches!(error.problem(), ManifestProblem::InvalidId { .. })); | |
| assert!(error.to_string().contains("script:<id>/<panel>")); | |
| } | |
| #[test] | |
| fn a_version_must_be_comparable() { | |
| let source = VALID.replacen("\"1.2.0\"", "\"latest\"", 1); | |
| let error = PluginManifest::parse(&source).expect_err("`latest` is not a version"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::InvalidVersion("latest".to_owned()) | |
| ); | |
| for version in ["1.2.0", "0.0.1", "1.2.0-beta.1", "2.0.0+build.5"] { | |
| let source = VALID.replacen("\"1.2.0\"", &format!("\"{version}\""), 1); | |
| assert!( | |
| PluginManifest::parse(&source).is_ok(), | |
| "`{version}` should parse" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn an_entry_outside_the_plugin_directory_is_refused() { | |
| let source = VALID.replacen("\"main.js\"", "\"../../etc/main.js\"", 1); | |
| let error = PluginManifest::parse(&source).expect_err("an entry may not escape"); | |
| assert!(matches!(error.problem(), ManifestProblem::InvalidEntry(_))); | |
| } | |
| #[test] | |
| fn an_unexpanded_placeholder_is_caught_before_it_becomes_a_directory() { | |
| let source = VALID.replacen("${dataDir}", "${desktopDir}", 1); | |
| let error = PluginManifest::parse(&source).expect_err("`${desktopDir}` does not exist"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::UnknownPlaceholder { | |
| field: "capabilities.fs.read".to_owned(), | |
| placeholder: "${desktopDir}".to_owned(), | |
| } | |
| ); | |
| } | |
| #[test] | |
| fn a_shell_spelling_of_home_is_refused_with_the_manifest_spelling() { | |
| for (written, suggestion) in [ | |
| ("$HOME/.claude", "${homeDir}/.claude"), | |
| ("${HOME}/.claude", "${homeDir}/.claude"), | |
| ("~/.claude", "${homeDir}/.claude"), | |
| ("~", "${homeDir}"), | |
| ("%USERPROFILE%\\\\.claude", "${homeDir}\\\\.claude"), | |
| ] { | |
| let source = VALID.replacen("${dataDir}", written, 1); | |
| let error = PluginManifest::parse(&source) | |
| .expect_err("a shell spelling of home must not become a relative directory"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::UnexpandedPath { | |
| field: "capabilities.fs.read".to_owned(), | |
| path: written.replace("\\\\", "\\"), | |
| suggestion: suggestion.replace("\\\\", "\\"), | |
| }, | |
| ); | |
| } | |
| // A directory that merely starts with the same letters is a real name. | |
| let source = VALID.replacen("${dataDir}", "~notes", 1); | |
| assert!(PluginManifest::parse(&source).is_ok()); | |
| } | |
| #[test] | |
| fn the_home_directory_placeholder_names_the_users_own_files() { | |
| let source = VALID.replacen( | |
| "\"read\": [\"${pluginDir}\", \"${dataDir}\"]", | |
| "\"read\": [\"${pluginDir}\", \"${homeDir}/.claude/skills\"]", | |
| 1, | |
| ); | |
| let manifest = PluginManifest::parse(&source).expect("`${homeDir}` is a placeholder"); | |
| let fs = manifest.capabilities.fs.as_ref().expect("an fs grant"); | |
| let home = Path::new("/home/example"); | |
| let anchors = Anchors { | |
| plugin_dir: Path::new("/plugins/a"), | |
| data_dir: Path::new("/data/a"), | |
| home_dir: Some(home), | |
| }; | |
| assert_eq!( | |
| expand_all(&fs.read, &anchors), | |
| [PathBuf::from("/plugins/a"), home.join(".claude/skills")] | |
| ); | |
| // Without a home directory the grant shrinks rather than landing in | |
| // a directory named `${homeDir}` inside the plugin. | |
| let homeless = Anchors { | |
| home_dir: None, | |
| ..anchors | |
| }; | |
| assert_eq!( | |
| expand_all(&fs.read, &homeless), | |
| [PathBuf::from("/plugins/a")] | |
| ); | |
| } | |
| #[test] | |
| fn environment_variables_are_granted_by_name() { | |
| let source = VALID.replacen( | |
| "\"process\": { \"exit\": true }", | |
| "\"process\": { \"exit\": true, \"env\": [\"EDITOR\"] }", | |
| 1, | |
| ); | |
| let manifest = PluginManifest::parse(&source).expect("`env` is a process grant"); | |
| let capabilities = manifest.capabilities(Path::new("/plugins/a"), Path::new("/data/a")); | |
| assert_eq!(capabilities.granted_env_variables(), ["EDITOR"]); | |
| assert!(capabilities.summary().contains(&"env: EDITOR".to_owned())); | |
| let source = VALID.replacen( | |
| "\"process\": { \"exit\": true }", | |
| "\"process\": { \"env\": [\"A=B\"] }", | |
| 1, | |
| ); | |
| let error = PluginManifest::parse(&source).expect_err("`A=B` is not a name"); | |
| assert!(matches!(error.problem(), ManifestProblem::Capabilities(_))); | |
| } | |
| #[test] | |
| fn an_unrestricted_execute_grant_is_spelled_differently_from_an_allowlist() { | |
| let source = VALID.replacen("[\"git\"]", "\"*\"", 1); | |
| let manifest = PluginManifest::parse(&source).expect("`*` is a valid execute grant"); | |
| let capabilities = manifest.capabilities(Path::new("/plugins/a"), Path::new("/data/a")); | |
| assert_eq!(capabilities.execute_grant(), &ExecuteGrant::Unrestricted); | |
| assert!(capabilities.may_run("anything")); | |
| } | |
| #[test] | |
| fn a_manifest_read_from_disk_carries_its_path() { | |
| let tree = TempTree::new("read"); | |
| let root = tree.plugin("inbox", VALID); | |
| let manifest = PluginManifest::read(&root).expect("the manifest should parse"); | |
| assert_eq!(manifest.id(), "com.example.inbox"); | |
| let broken = tree.plugin("broken", "{ \"id\": \"a.b\" }"); | |
| let error = PluginManifest::read(&broken).expect_err("`name` is missing"); | |
| assert_eq!(error.path(), Some(broken.join(MANIFEST_FILE).as_path())); | |
| assert!(error.to_string().contains(MANIFEST_FILE)); | |
| } | |
| #[test] | |
| fn discovery_returns_the_broken_manifest_beside_the_good_one() { | |
| let tree = TempTree::new("discover"); | |
| tree.plugin("a-good", VALID); | |
| tree.plugin("b-broken", "{ \"id\": \"com.example.broken\" }"); | |
| // Not a plugin: no manifest, so it is not reported at all. | |
| std::fs::create_dir_all(tree.path().join("c-not-a-plugin")) | |
| .expect("cannot create a directory"); | |
| let mut manager = PluginManager::new(vec![tree.path().to_path_buf()]); | |
| let results = manager.discover(); | |
| assert_eq!(results.len(), 2, "{results:?}"); | |
| assert_eq!( | |
| results[0].as_ref().expect("the first is valid").id(), | |
| "com.example.inbox" | |
| ); | |
| let error = results[1].as_ref().expect_err("the second is broken"); | |
| assert_eq!(error.problem(), &ManifestProblem::MissingField("name")); | |
| // Only the readable manifest reached the catalog, and nothing ran. | |
| let available: Vec<&str> = manager.available().map(PluginManifest::id).collect(); | |
| assert_eq!(available, vec!["com.example.inbox"]); | |
| assert_eq!(manager.loaded().count(), 0); | |
| } | |
| #[test] | |
| fn a_directory_that_is_itself_a_plugin_is_discovered() { | |
| let tree = TempTree::new("single"); | |
| let root = tree.plugin("inbox", VALID); | |
| let mut manager = PluginManager::new(vec![root]); | |
| let results = manager.discover(); | |
| assert_eq!(results.len(), 1); | |
| assert!(results[0].is_ok()); | |
| } | |
| #[test] | |
| fn the_same_id_in_two_directories_is_reported_once_and_refused_once() { | |
| let first = TempTree::new("dup-first"); | |
| let second = TempTree::new("dup-second"); | |
| first.plugin("inbox", VALID); | |
| second.plugin("inbox", VALID); | |
| let mut manager = PluginManager::new(vec![ | |
| first.path().to_path_buf(), | |
| second.path().to_path_buf(), | |
| ]); | |
| let results = manager.discover(); | |
| assert_eq!(results.len(), 2); | |
| assert!(results[0].is_ok()); | |
| assert!( | |
| matches!( | |
| results[1] | |
| .as_ref() | |
| .expect_err("the copy is refused") | |
| .problem(), | |
| ManifestProblem::DuplicateId { .. } | |
| ), | |
| "the second copy must not win silently" | |
| ); | |
| assert_eq!(manager.available().count(), 1); | |
| } | |
| #[test] | |
| fn storage_is_keyed_by_id_not_by_path() { | |
| let manager = | |
| PluginManager::new(Vec::new()).with_data_home(PathBuf::from("/home/user/.local/share")); | |
| assert_eq!( | |
| manager.data_dir("com.example.inbox"), | |
| PathBuf::from("/home/user/.local/share/gpui-shell/plugins/com.example.inbox") | |
| ); | |
| } | |
| #[test] | |
| fn the_schema_type_accepts_exactly_what_the_parser_accepts() { | |
| // Two readers of one file: `schemars` describes `ManifestFile`, while | |
| // `parse` walks the fields by hand to explain each failure. This is the | |
| // check that they still describe the same file. | |
| let described: ManifestFile = | |
| serde_json::from_str(VALID).expect("the schema type must accept a valid manifest"); | |
| let parsed = PluginManifest::parse(VALID).expect("and so must the parser"); | |
| assert_eq!(described.id, parsed.id); | |
| assert_eq!(described.name, parsed.name); | |
| assert_eq!( | |
| described.version.as_deref().unwrap_or("unknown"), | |
| parsed.version | |
| ); | |
| assert_eq!( | |
| described.shell_version.as_deref().unwrap_or(SHELL_VERSION), | |
| parsed.shell_version | |
| ); | |
| assert_eq!(described.entry, parsed.entry); | |
| assert_eq!(described.dependencies, parsed.dependencies); | |
| assert_eq!(described.capabilities, parsed.capabilities); | |
| assert_eq!(described.window, parsed.window); | |
| } | |
| #[test] | |
| fn the_schema_describes_every_field() { | |
| let schema = manifest_schema().to_string(); | |
| for field in FIELDS { | |
| assert!(schema.contains(field), "the schema must mention `{field}`"); | |
| } | |
| for grant in [ | |
| "fs", | |
| "read", | |
| "write", | |
| "execute", | |
| "network", | |
| "hosts", | |
| "http", | |
| "scheme", | |
| "port", | |
| "methods", | |
| "paths", | |
| "path_prefixes", | |
| "storage", | |
| "clipboard", | |
| "process", | |
| "exit", | |
| "env", | |
| ] { | |
| assert!( | |
| schema.contains(grant), | |
| "the schema must mention `{grant}` inside capabilities" | |
| ); | |
| } | |
| // A seventh field must be a schema violation, not a silently ignored key. | |
| assert!( | |
| schema.contains("additionalProperties"), | |
| "the schema must refuse unknown fields as the parser does" | |
| ); | |
| } | |
| #[test] | |
| fn incompatible_shell_versions_are_rejected_before_discovery() { | |
| let runtime = Version::parse(SHELL_VERSION).expect("the crate version is semantic"); | |
| let next_minor = Version::new(runtime.major, runtime.minor + 1, 0); | |
| let next_major = Version::new(runtime.major + 1, 0, 0); | |
| for required in [next_minor, next_major] { | |
| let source = VALID.replacen( | |
| &format!("\"{SHELL_VERSION}\""), | |
| &format!("\"{required}\""), | |
| 1, | |
| ); | |
| let error = PluginManifest::parse(&source).expect_err("incompatible shell version"); | |
| assert!( | |
| matches!( | |
| error.problem(), | |
| ManifestProblem::IncompatibleShellVersion { .. } | |
| ), | |
| "{error}" | |
| ); | |
| } | |
| } | |
| #[test] | |
| fn older_shell_minor_versions_remain_compatible() { | |
| let source = VALID.replacen("\"0.1.0\"", "\"0.0.0\"", 1); | |
| PluginManifest::parse(&source) | |
| .expect("a manifest's shell-version is its minimum runtime version"); | |
| } | |
| #[test] | |
| fn malformed_shell_versions_are_errors_not_panics() { | |
| for required in ["0.1.0-", "00.1.0", "0.1.184467440737095516160", "latest"] { | |
| let source = VALID.replacen( | |
| &format!("\"{SHELL_VERSION}\""), | |
| &format!("\"{required}\""), | |
| 1, | |
| ); | |
| let error = PluginManifest::parse(&source).expect_err("invalid semantic version"); | |
| assert_eq!( | |
| error.problem(), | |
| &ManifestProblem::InvalidShellVersion(required.to_owned()) | |
| ); | |
| } | |
| } | |
| } |