From cd9105291cdb5975b6f7e9106807dc2b810d62fe Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Sat, 13 Jun 2026 21:46:20 +0300 Subject: [PATCH] refactor: split plugin api module --- src/app/api/plugins/context.rs | 379 +++++ src/app/api/plugins/manifest.rs | 539 +++++++ src/app/api/{plugins.rs => plugins/mod.rs} | 1468 +------------------- src/app/api/plugins/panes.rs | 280 ++++ src/app/api/plugins/runtime.rs | 270 ++++ 5 files changed, 1488 insertions(+), 1448 deletions(-) create mode 100644 src/app/api/plugins/context.rs create mode 100644 src/app/api/plugins/manifest.rs rename src/app/api/{plugins.rs => plugins/mod.rs} (62%) create mode 100644 src/app/api/plugins/panes.rs create mode 100644 src/app/api/plugins/runtime.rs diff --git a/src/app/api/plugins/context.rs b/src/app/api/plugins/context.rs new file mode 100644 index 00000000..881020b8 --- /dev/null +++ b/src/app/api/plugins/context.rs @@ -0,0 +1,379 @@ +use crate::api::schema::{EventData, PluginInvocationContext}; +use crate::app::App; + +impl App { + pub(super) fn merge_plugin_context( + &self, + provided: Option, + correlation_id: &str, + ) -> PluginInvocationContext { + let mut context = self.current_plugin_context(correlation_id); + if let Some(provided) = provided { + context.workspace_id = provided.workspace_id.or(context.workspace_id); + context.workspace_label = provided.workspace_label.or(context.workspace_label); + context.workspace_cwd = provided.workspace_cwd.or(context.workspace_cwd); + context.worktree = provided.worktree.or(context.worktree); + context.tab_id = provided.tab_id.or(context.tab_id); + context.tab_label = provided.tab_label.or(context.tab_label); + context.focused_pane_id = provided.focused_pane_id.or(context.focused_pane_id); + context.focused_pane_cwd = provided.focused_pane_cwd.or(context.focused_pane_cwd); + context.focused_pane_agent = provided.focused_pane_agent.or(context.focused_pane_agent); + context.focused_pane_status = + provided.focused_pane_status.or(context.focused_pane_status); + context.selected_text = provided.selected_text.or(context.selected_text); + context.invocation_source = provided.invocation_source.or(context.invocation_source); + context.correlation_id = provided.correlation_id.or(context.correlation_id); + context.clicked_url = provided.clicked_url.or(context.clicked_url); + context.link_handler_id = provided.link_handler_id.or(context.link_handler_id); + } + context + } + + pub(super) fn current_plugin_context(&self, correlation_id: &str) -> PluginInvocationContext { + let Some(ws_idx) = self.state.active else { + return empty_plugin_context(correlation_id); + }; + self.plugin_context_for_workspace(ws_idx, correlation_id) + } + + pub(super) fn plugin_context_for_event( + &self, + event: &crate::api::schema::EventEnvelope, + correlation_id: &str, + ) -> PluginInvocationContext { + match &event.data { + EventData::WorkspaceCreated { workspace } + | EventData::WorkspaceUpdated { workspace } + | EventData::WorktreeCreated { workspace, .. } + | EventData::WorktreeOpened { workspace, .. } => { + self.plugin_context_for_workspace_info(workspace, correlation_id) + } + EventData::WorkspaceClosed { + workspace_id, + workspace, + } => workspace + .as_ref() + .map(|workspace| self.plugin_context_for_workspace_info(workspace, correlation_id)) + .unwrap_or_else(|| { + self.plugin_context_for_workspace_id(workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context + }) + }), + EventData::WorkspaceRenamed { workspace_id, .. } + | EventData::WorkspaceFocused { workspace_id } + | EventData::WorktreeRemoved { workspace_id, .. } => self + .plugin_context_for_workspace_id(workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context + }), + EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id), + EventData::TabClosed { + tab_id, + workspace_id, + } => { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.tab_id = Some(tab_id.clone()); + context + } + EventData::TabRenamed { + tab_id, + workspace_id, + .. + } + | EventData::TabFocused { + tab_id, + workspace_id, + } => self + .plugin_context_for_tab_id(tab_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.tab_id = Some(tab_id.clone()); + context + }), + EventData::PaneCreated { pane } => { + self.plugin_context_for_pane_info(pane, correlation_id) + } + EventData::PaneMoved { pane, .. } => { + self.plugin_context_for_pane_info(pane.as_ref(), correlation_id) + } + EventData::PaneClosed { + pane_id, + workspace_id, + } => { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.focused_pane_id = Some(pane_id.clone()); + context + } + EventData::PaneFocused { + pane_id, + workspace_id, + } + | EventData::PaneOutputChanged { + pane_id, + workspace_id, + .. + } + | EventData::PaneExited { + pane_id, + workspace_id, + } + | EventData::PaneAgentDetected { + pane_id, + workspace_id, + .. + } + | EventData::PaneAgentStatusChanged { + pane_id, + workspace_id, + .. + } => self + .plugin_context_for_public_pane_id(pane_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace_id.clone()); + context.focused_pane_id = Some(pane_id.clone()); + context + }), + } + } + + fn plugin_context_for_workspace_id( + &self, + workspace_id: &str, + correlation_id: &str, + ) -> Option { + let ws_idx = self + .state + .workspaces + .iter() + .enumerate() + .find_map(|(idx, _)| (self.public_workspace_id(idx) == workspace_id).then_some(idx))?; + Some(self.plugin_context_for_workspace(ws_idx, correlation_id)) + } + + fn plugin_context_for_workspace_info( + &self, + workspace: &crate::api::schema::WorkspaceInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(workspace.workspace_id.clone()); + context.workspace_label = Some(workspace.label.clone()); + context.workspace_cwd = workspace + .worktree + .as_ref() + .map(|worktree| worktree.checkout_path.clone()); + context.worktree = workspace.worktree.clone(); + context.tab_id = Some(workspace.active_tab_id.clone()); + context + }) + } + + fn plugin_context_for_tab_id( + &self, + tab_id: &str, + correlation_id: &str, + ) -> Option { + let (ws_idx, tab_idx) = self.parse_tab_id(tab_id)?; + let ws = self.state.workspaces.get(ws_idx)?; + let workspace = self.workspace_info(ws_idx); + let tab = ws.tabs.get(tab_idx)?; + let pane_id = tab.layout.focused(); + let focused_pane = self.pane_info(ws_idx, pane_id); + Some(self.plugin_context_from_parts( + ws_idx, + workspace, + self.public_tab_id(ws_idx, tab_idx), + Some(tab.display_name()), + focused_pane, + correlation_id, + )) + } + + fn plugin_context_for_tab_info( + &self, + tab: &crate::api::schema::TabInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_tab_id(&tab.tab_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(&tab.workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(tab.workspace_id.clone()); + context.tab_id = Some(tab.tab_id.clone()); + context.tab_label = Some(tab.label.clone()); + context + }) + } + + fn plugin_context_for_public_pane_id( + &self, + pane_id: &str, + correlation_id: &str, + ) -> Option { + let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?; + Some(self.plugin_context_for_pane(ws_idx, pane_id, correlation_id)) + } + + fn plugin_context_for_pane_info( + &self, + pane: &crate::api::schema::PaneInfo, + correlation_id: &str, + ) -> PluginInvocationContext { + self.plugin_context_for_public_pane_id(&pane.pane_id, correlation_id) + .or_else(|| self.plugin_context_for_workspace_id(&pane.workspace_id, correlation_id)) + .unwrap_or_else(|| { + let mut context = empty_plugin_context(correlation_id); + context.workspace_id = Some(pane.workspace_id.clone()); + context.tab_id = Some(pane.tab_id.clone()); + context.focused_pane_id = Some(pane.pane_id.clone()); + context.focused_pane_cwd = pane.cwd.clone(); + context.focused_pane_agent = pane.agent.clone(); + context.focused_pane_status = Some(pane.agent_status); + context + }) + } + + pub(super) fn plugin_context_for_workspace( + &self, + ws_idx: usize, + correlation_id: &str, + ) -> PluginInvocationContext { + let Some(ws) = self.state.workspaces.get(ws_idx) else { + return empty_plugin_context(correlation_id); + }; + let workspace = self.workspace_info(ws_idx); + let tab_idx = ws.active_tab_index(); + let tab_id = self.public_tab_id(ws_idx, tab_idx); + let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let focused_pane = ws + .focused_pane_id() + .and_then(|pane_id| self.pane_info(ws_idx, pane_id)); + self.plugin_context_from_parts( + ws_idx, + workspace, + tab_id, + tab_label, + focused_pane, + correlation_id, + ) + } + + pub(super) fn plugin_context_for_pane( + &self, + ws_idx: usize, + pane_id: crate::layout::PaneId, + correlation_id: &str, + ) -> PluginInvocationContext { + let ws = &self.state.workspaces[ws_idx]; + let workspace = self.workspace_info(ws_idx); + let tab_idx = ws + .find_tab_index_for_pane(pane_id) + .unwrap_or_else(|| ws.active_tab_index()); + let tab_id = self.public_tab_id(ws_idx, tab_idx); + let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); + let focused_pane = self.pane_info(ws_idx, pane_id); + self.plugin_context_from_parts( + ws_idx, + workspace, + tab_id, + tab_label, + focused_pane, + correlation_id, + ) + } + + fn plugin_context_from_parts( + &self, + ws_idx: usize, + workspace: crate::api::schema::WorkspaceInfo, + tab_id: Option, + tab_label: Option, + focused_pane: Option, + correlation_id: &str, + ) -> PluginInvocationContext { + let workspace_cwd = focused_pane + .as_ref() + .and_then(|pane| pane.cwd.clone()) + .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); + let selected_text = focused_pane + .as_ref() + .and_then(|pane| self.parse_pane_id(&pane.pane_id)) + .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); + PluginInvocationContext { + workspace_id: Some(workspace.workspace_id), + workspace_label: Some(workspace.label), + workspace_cwd, + worktree: workspace.worktree, + tab_id, + tab_label, + focused_pane_id: focused_pane.as_ref().map(|pane| pane.pane_id.clone()), + focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), + focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), + focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), + selected_text, + invocation_source: Some("api".to_string()), + correlation_id: Some(correlation_id.to_string()), + clicked_url: None, + link_handler_id: None, + } + } + + fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option { + let selection = self.state.selection.as_ref()?; + if selection.pane_id != pane_id || !selection.is_visible() { + return None; + } + let terminal_id = self + .state + .workspaces + .iter() + .find_map(|workspace| workspace.terminal_id(pane_id))?; + self.terminal_runtimes + .get(terminal_id) + .and_then(|runtime| runtime.extract_selection(selection)) + .filter(|text| !text.is_empty()) + } + + fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { + self.state + .workspaces + .get(ws_idx) + .and_then(|ws| { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + }) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())) + } +} + +fn empty_plugin_context(correlation_id: &str) -> PluginInvocationContext { + PluginInvocationContext { + workspace_id: None, + workspace_label: None, + workspace_cwd: None, + worktree: None, + tab_id: None, + tab_label: None, + focused_pane_id: None, + focused_pane_cwd: None, + focused_pane_agent: None, + focused_pane_status: None, + selected_text: None, + invocation_source: Some("api".to_string()), + correlation_id: Some(correlation_id.to_string()), + clicked_url: None, + link_handler_id: None, + } +} diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs new file mode 100644 index 00000000..92799aae --- /dev/null +++ b/src/app/api/plugins/manifest.rs @@ -0,0 +1,539 @@ +use crate::api::schema::{ + InstalledPluginInfo, PluginManifestAction, PluginManifestBuild, PluginManifestEventHook, + PluginManifestLinkHandler, PluginManifestPane, PluginPanePlacement, PluginPlatform, + PluginSourceInfo, PluginSourceKind, +}; + +const PLUGIN_ID_MAX_CHARS: usize = 120; +const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; + +#[derive(serde::Deserialize)] +struct RawPluginManifest { + id: String, + name: String, + version: String, + #[serde(default)] + description: Option, + #[serde(default)] + platforms: Option>, + #[serde(default)] + build: Vec, + #[serde(default)] + actions: Vec, + #[serde(default)] + events: Vec, + #[serde(default)] + panes: Vec, + #[serde(default)] + link_handlers: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestBuild { + #[serde(default)] + platforms: Option>, + command: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestAction { + id: String, + title: String, + #[serde(default)] + description: Option, + #[serde(default)] + contexts: Vec, + #[serde(default)] + platforms: Option>, + command: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestEventHook { + on: String, + #[serde(default)] + platforms: Option>, + command: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestPane { + id: String, + title: String, + #[serde(default)] + description: Option, + #[serde(default)] + platforms: Option>, + #[serde(default)] + placement: PluginPanePlacement, + command: Vec, +} + +#[derive(serde::Deserialize)] +struct RawPluginManifestLinkHandler { + id: String, + title: String, + pattern: String, + action: String, + #[serde(default)] + platforms: Option>, +} + +/// Raw string platform value from the manifest, validated before conversion. +#[derive(serde::Deserialize)] +#[serde(try_from = "String")] +struct RawPlatform(PluginPlatform); + +impl TryFrom for RawPlatform { + type Error = String; + + fn try_from(value: String) -> Result { + match value.as_str() { + "linux" => Ok(RawPlatform(PluginPlatform::Linux)), + "macos" => Ok(RawPlatform(PluginPlatform::Macos)), + "windows" => Ok(RawPlatform(PluginPlatform::Windows)), + other => Err(format!( + "invalid_plugin_platform: unknown platform '{other}'" + )), + } + } +} + +pub(crate) fn load_plugin_manifest( + path: &str, + enabled: bool, +) -> Result { + let path = std::path::PathBuf::from(path); + let manifest_path = if path.is_dir() { + path.join("herdr-plugin.toml") + } else { + path + }; + let manifest_path = manifest_path + .canonicalize() + .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?; + let plugin_root = manifest_path + .parent() + .ok_or_else(|| { + ( + "invalid_plugin_manifest_path", + "manifest path has no parent directory".to_string(), + ) + })? + .to_path_buf(); + let content = std::fs::read_to_string(&manifest_path) + .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?; + let raw: RawPluginManifest = toml::from_str(&content) + .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?; + let plugin_id = normalize_plugin_id(&raw.id) + .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; + let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?; + let version = non_empty_trimmed( + &raw.version, + "invalid_plugin_version", + "plugin version is required", + )?; + let description = raw + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(raw.platforms)?; + let build = raw + .build + .into_iter() + .map(normalize_manifest_build) + .collect::, _>>()?; + let mut actions = raw + .actions + .into_iter() + .map(normalize_manifest_action) + .collect::, _>>()?; + reject_duplicate_action_ids(&actions)?; + actions.sort_by(|a, b| a.id.cmp(&b.id)); + let mut events = raw + .events + .into_iter() + .map(normalize_manifest_event) + .collect::, _>>()?; + events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command))); + let mut panes = raw + .panes + .into_iter() + .map(normalize_manifest_pane) + .collect::, _>>()?; + reject_duplicate_pane_ids(&panes)?; + panes.sort_by(|a, b| a.id.cmp(&b.id)); + let link_handlers = raw + .link_handlers + .into_iter() + .map(normalize_manifest_link_handler) + .collect::, _>>()?; + reject_duplicate_link_handler_ids(&link_handlers)?; + validate_link_handler_actions(&link_handlers, &actions)?; + + let mut warnings = validate_event_names(&events); + if platforms.is_none() { + warnings.push("manifest does not declare platforms; platform support unknown".to_string()); + } + + Ok(InstalledPluginInfo { + plugin_id, + name, + version, + description, + manifest_path: manifest_path.display().to_string(), + plugin_root: plugin_root.display().to_string(), + enabled, + platforms, + build, + actions, + events, + panes, + link_handlers, + source: Default::default(), + warnings, + }) +} + +fn normalize_manifest_build( + build: RawPluginManifestBuild, +) -> Result { + let platforms = normalize_platforms(build.platforms)?; + let command = normalize_command(build.command)?; + Ok(PluginManifestBuild { platforms, command }) +} + +pub(super) fn normalize_plugin_source( + plugin: &InstalledPluginInfo, + source: PluginSourceInfo, +) -> Result { + if source.kind == PluginSourceKind::Local { + return Ok(source); + } + let Some(managed_path) = source.managed_path.as_deref() else { + return Err(( + "invalid_plugin_source", + "GitHub plugin source requires managed_path".to_string(), + )); + }; + let managed_path = std::path::PathBuf::from(managed_path) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + let expected = crate::session::data_dir() + .join("plugins") + .join("github") + .join(crate::api::schema::plugin_managed_path_component( + &plugin.plugin_id, + )) + .canonicalize() + .map_err(|err| ("invalid_plugin_source", err.to_string()))?; + if managed_path != expected { + return Err(( + "invalid_plugin_source", + "GitHub plugin managed_path does not match the plugin id".to_string(), + )); + } + if !plugin_root.starts_with(&managed_path) { + return Err(( + "invalid_plugin_source", + "plugin manifest is not inside the managed checkout".to_string(), + )); + } + Ok(source) +} + +fn reject_duplicate_action_ids( + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for action in actions { + if !seen.insert(action.id.as_str()) { + return Err(( + "duplicate_plugin_action_id", + format!("duplicate action id '{}'", action.id), + )); + } + } + Ok(()) +} + +fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec { + let known = crate::api::schema::plugin_hook_event_names(); + events + .iter() + .filter(|hook| !known.contains(&hook.on.as_str())) + .map(|hook| format!("unknown event '{}'", hook.on)) + .collect() +} + +fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for pane in panes { + if !seen.insert(pane.id.as_str()) { + return Err(( + "duplicate_plugin_pane_id", + format!("duplicate pane id '{}'", pane.id), + )); + } + } + Ok(()) +} + +fn reject_duplicate_link_handler_ids( + handlers: &[PluginManifestLinkHandler], +) -> Result<(), (&'static str, String)> { + let mut seen = std::collections::HashSet::new(); + for handler in handlers { + if !seen.insert(handler.id.as_str()) { + return Err(( + "duplicate_plugin_link_handler_id", + format!("duplicate link handler id '{}'", handler.id), + )); + } + } + Ok(()) +} + +fn validate_link_handler_actions( + handlers: &[PluginManifestLinkHandler], + actions: &[PluginManifestAction], +) -> Result<(), (&'static str, String)> { + for handler in handlers { + if !actions.iter().any(|action| action.id == handler.action) { + return Err(( + "invalid_plugin_link_handler_action", + format!( + "link handler '{}' references unknown action '{}'", + handler.id, handler.action + ), + )); + } + } + Ok(()) +} + +fn normalize_manifest_action( + action: RawPluginManifestAction, +) -> Result { + let id = normalize_action_id(&action.id) + .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; + let title = non_empty_trimmed( + &action.title, + "invalid_plugin_action_title", + "action title is required", + )?; + let description = action + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(action.platforms)?; + let command = normalize_command(action.command)?; + Ok(PluginManifestAction { + id, + title, + description, + contexts: action.contexts, + platforms, + command, + }) +} + +fn normalize_manifest_pane( + pane: RawPluginManifestPane, +) -> Result { + let id = normalize_action_id(&pane.id) + .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?; + let title = non_empty_trimmed( + &pane.title, + "invalid_plugin_pane_title", + "pane title is required", + )?; + let description = pane + .description + .map(|description| description.trim().to_string()) + .filter(|description| !description.is_empty()); + let platforms = normalize_platforms(pane.platforms)?; + let command = normalize_command(pane.command)?; + Ok(PluginManifestPane { + id, + title, + description, + platforms, + placement: pane.placement, + command, + }) +} + +fn normalize_manifest_event( + event: RawPluginManifestEventHook, +) -> Result { + let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?; + let platforms = normalize_platforms(event.platforms)?; + let command = normalize_command(event.command)?; + Ok(PluginManifestEventHook { + on, + platforms, + command, + }) +} + +fn normalize_manifest_link_handler( + handler: RawPluginManifestLinkHandler, +) -> Result { + let id = normalize_action_id(&handler.id).ok_or_else(|| { + ( + "invalid_plugin_link_handler_id", + "invalid link handler id".to_string(), + ) + })?; + let title = non_empty_trimmed( + &handler.title, + "invalid_plugin_link_handler_title", + "link handler title is required", + )?; + let pattern = non_empty_trimmed( + &handler.pattern, + "invalid_plugin_link_handler_pattern", + "link handler pattern is required", + )?; + regex::Regex::new(&pattern) + .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?; + let action = normalize_action_id(&handler.action).ok_or_else(|| { + ( + "invalid_plugin_link_handler_action", + "invalid link handler action".to_string(), + ) + })?; + let platforms = normalize_platforms(handler.platforms)?; + Ok(PluginManifestLinkHandler { + id, + title, + pattern, + action, + platforms, + }) +} + +fn normalize_platforms( + raw: Option>, +) -> Result>, (&'static str, String)> { + match raw { + None => Ok(None), + Some(list) if list.is_empty() => Err(( + "invalid_plugin_platform", + "platforms must not be an empty array; omit the field to leave platforms undeclared" + .to_string(), + )), + Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())), + } +} + +/// Returns the platform the current binary was compiled for. +fn current_platform() -> PluginPlatform { + if cfg!(target_os = "linux") { + PluginPlatform::Linux + } else if cfg!(target_os = "macos") { + PluginPlatform::Macos + } else { + PluginPlatform::Windows + } +} + +/// Resolve the effective platforms for an action or event: use the item's own +/// platforms if declared, otherwise inherit from the plugin-level platforms. +/// Returns a reference to whichever `Option>` applies. +pub(super) fn effective_platforms<'a>( + item_platforms: &'a Option>, + plugin_platforms: &'a Option>, +) -> &'a Option> { + if item_platforms.is_some() { + item_platforms + } else { + plugin_platforms + } +} + +pub(super) fn ensure_platform_supported( + platforms: &Option>, + subject: &str, +) -> Result<(), (&'static str, String)> { + if let Some(platforms) = platforms { + let host = current_platform(); + if !platforms.contains(&host) { + return Err(( + "platform_unsupported", + format!( + "{subject} does not support the current platform ({})", + platform_name(host) + ), + )); + } + } + Ok(()) +} + +fn platform_name(p: PluginPlatform) -> &'static str { + match p { + PluginPlatform::Linux => "linux", + PluginPlatform::Macos => "macos", + PluginPlatform::Windows => "windows", + } +} + +fn normalize_command(command: Vec) -> Result, (&'static str, String)> { + let command = command + .into_iter() + .map(|arg| arg.trim().to_string()) + .collect::>(); + if command.is_empty() || command.iter().any(|arg| arg.is_empty()) { + return Err(( + "invalid_plugin_command", + "command must contain non-empty argv strings".to_string(), + )); + } + Ok(command) +} + +fn non_empty_trimmed( + value: &str, + code: &'static str, + message: &'static str, +) -> Result { + let value = value.trim().to_string(); + if value.is_empty() { + Err((code, message.to_string())) + } else { + Ok(value) + } +} + +pub(super) fn normalize_plugin_id(value: &str) -> Option { + normalize_identifier(value, PLUGIN_ID_MAX_CHARS) +} + +pub(super) fn normalize_action_id(value: &str) -> Option { + normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS) +} + +fn normalize_identifier(value: &str, max_chars: usize) -> Option { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-'))) + .then(|| value.to_string()) +} + +fn normalize_local_identifier(value: &str, max_chars: usize) -> Option { + let value = value.trim(); + (!value.is_empty() + && value.chars().count() <= max_chars + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))) + .then(|| value.to_string()) +} diff --git a/src/app/api/plugins.rs b/src/app/api/plugins/mod.rs similarity index 62% rename from src/app/api/plugins.rs rename to src/app/api/plugins/mod.rs index e90a8f03..8204efbc 100644 --- a/src/app/api/plugins.rs +++ b/src/app/api/plugins/mod.rs @@ -1,25 +1,28 @@ -use std::io::Read; -use std::process::{Command, Stdio}; - -use ratatui::layout::Direction; +mod context; +mod manifest; +mod panes; +mod runtime; use super::responses::{encode_error, encode_success}; use crate::api::schema::{ InstalledPluginInfo, PluginActionInfo, PluginActionInvokeParams, PluginActionListParams, - PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext, PluginLinkParams, - PluginListParams, PluginLogListParams, PluginManifestAction, PluginManifestBuild, - PluginManifestEventHook, PluginManifestLinkHandler, PluginManifestPane, PluginPaneCloseParams, - PluginPaneFocusParams, PluginPaneInfo, PluginPaneOpenParams, PluginPanePlacement, - PluginPlatform, PluginSetEnabledParams, PluginSourceInfo, PluginSourceKind, PluginUnlinkParams, + PluginLinkParams, PluginListParams, PluginLogListParams, PluginManifestAction, + PluginManifestLinkHandler, PluginPaneCloseParams, PluginPaneFocusParams, PluginPaneInfo, + PluginPaneOpenParams, PluginPanePlacement, PluginSetEnabledParams, PluginUnlinkParams, ResponseResult, }; use crate::app::App; +use manifest::{ + effective_platforms, ensure_platform_supported, normalize_action_id, normalize_plugin_id, + normalize_plugin_source, +}; + +#[cfg(test)] +use crate::api::schema::{PluginCommandStatus, PluginInvocationContext}; +pub(crate) use manifest::load_plugin_manifest; +#[cfg(test)] +use runtime::{read_capped_plugin_output, MAX_PLUGIN_COMMANDS_IN_FLIGHT}; -const PLUGIN_ID_MAX_CHARS: usize = 120; -const PLUGIN_ACTION_ID_MAX_CHARS: usize = 120; -const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024; -const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32; -const PLUGIN_COMMAND_LOG_LIMIT: usize = 200; impl App { pub(super) fn handle_plugin_link(&mut self, id: String, params: PluginLinkParams) -> String { let mut plugin = match load_plugin_manifest(¶ms.path, params.enabled) { @@ -440,250 +443,6 @@ impl App { encode_success(id, ResponseResult::PluginPaneClosed { pane_id }) } - fn open_plugin_overlay_pane( - &mut self, - id: String, - params: PluginPaneOpenParams, - plugin: &InstalledPluginInfo, - pane: PluginManifestPane, - ) -> String { - let context = self.current_plugin_context("plugin-pane"); - let extra_env = - match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); - let (ws_idx, new_pane) = - match self.spawn_overlay_argv_command(&pane.command, cwd, extra_env, Vec::new()) { - Ok(result) => result, - Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), - }; - self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) - } - - fn open_plugin_split_pane( - &mut self, - id: String, - params: PluginPaneOpenParams, - plugin: &InstalledPluginInfo, - pane: PluginManifestPane, - placement: PluginPanePlacement, - ) -> String { - let target_pane_id = params - .target_pane_id - .clone() - .or_else(|| self.current_public_pane_id()); - let Some(target_pane_id) = target_pane_id else { - return encode_error(id, "no_active_pane", "no active pane"); - }; - let Some((ws_idx, target_pane)) = self.parse_pane_id(&target_pane_id) else { - return encode_error( - id, - "pane_not_found", - format!("pane {target_pane_id} not found"), - ); - }; - let context = self.plugin_context_for_pane(ws_idx, target_pane, "plugin-pane"); - let extra_env = - match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let direction = match params - .direction - .unwrap_or(crate::api::schema::SplitDirection::Right) - { - crate::api::schema::SplitDirection::Right => Direction::Horizontal, - crate::api::schema::SplitDirection::Down => Direction::Vertical, - }; - let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); - let (rows, cols) = self.state.estimate_pane_size(); - let previous_focus = self.state.current_pane_focus_target(); - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return encode_error(id, "workspace_not_found", "workspace not found"); - }; - let result = ws.split_pane_argv_command( - target_pane, - direction, - rows.max(4), - cols.max(10), - cwd, - &pane.command, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - params.focus || placement == PluginPanePlacement::Zoomed, - ); - let (tab_idx, new_pane) = match result { - Some(Ok(result)) => result, - Some(Err(err)) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), - None => { - return encode_error( - id, - "pane_not_found", - format!("pane {target_pane_id} not found"), - ) - } - }; - if params.focus || placement == PluginPanePlacement::Zoomed { - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state - .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); - self.state.mode = crate::app::Mode::Terminal; - } - if placement == PluginPanePlacement::Zoomed { - if let Some(tab) = self - .state - .workspaces - .get_mut(ws_idx) - .and_then(|ws| ws.tabs.get_mut(tab_idx)) - { - tab.zoomed = true; - } - } - self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) - } - - fn open_plugin_tab( - &mut self, - id: String, - params: PluginPaneOpenParams, - plugin: &InstalledPluginInfo, - pane: PluginManifestPane, - ) -> String { - let ws_idx = match params.workspace_id.as_deref() { - Some(workspace_id) => match self.parse_workspace_id(workspace_id) { - Some(ws_idx) => ws_idx, - None => return encode_error(id, "workspace_not_found", "workspace not found"), - }, - None => match self.state.active { - Some(ws_idx) => ws_idx, - None => return encode_error(id, "no_active_workspace", "no active workspace"), - }, - }; - let cwd = self.plugin_pane_cwd(plugin, params.cwd); - let context = self.plugin_context_for_workspace(ws_idx, "plugin-pane"); - let extra_env = - match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { - Ok(env) => env, - Err((code, message)) => return encode_error(id, &code, message), - }; - let (rows, cols) = self.state.estimate_pane_size(); - let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { - return encode_error(id, "workspace_not_found", "workspace not found"); - }; - let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command( - rows.max(4), - cols.max(10), - cwd, - &pane.command, - extra_env, - self.state.pane_scrollback_limit_bytes, - self.state.host_terminal_theme, - ) { - Ok(result) => result, - Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), - }; - let pane_id = ws.tabs[tab_idx].root_pane; - if params.focus { - self.state.switch_workspace_tab(ws_idx, tab_idx); - self.state.mode = crate::app::Mode::Terminal; - } - let new_pane = crate::workspace::NewPane { - pane_id, - terminal, - runtime, - }; - self.finish_plugin_pane_open( - id, - ws_idx, - Some(tab_idx), - new_pane, - plugin.plugin_id.clone(), - pane, - ) - } - - fn plugin_pane_launch_env( - &self, - plugin: &InstalledPluginInfo, - entrypoint: &str, - env: std::collections::HashMap, - context: &PluginInvocationContext, - ) -> Result, (String, String)> { - let mut env = super::env::normalize_launch_env(env)?; - let context_json = serde_json::to_string(&context) - .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?; - env.retain(|(key, _)| !plugin_pane_protected_env_key(key)); - env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone())); - env.push(( - "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), - entrypoint.to_string(), - )); - env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json)); - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - Ok(env) - } - - fn finish_plugin_pane_open( - &mut self, - id: String, - ws_idx: usize, - created_tab_idx: Option, - new_pane: crate::workspace::NewPane, - plugin_id: String, - pane_manifest: PluginManifestPane, - ) -> String { - let entrypoint = pane_manifest.id.clone(); - let mut terminal = new_pane.terminal; - terminal.set_manual_label(pane_manifest.title.clone()); - let terminal_id = terminal.id.clone(); - self.terminal_runtimes - .insert(terminal_id.clone(), new_pane.runtime); - self.state - .remove_alias_shadowed_by_new_pane(new_pane.pane_id); - self.state.terminals.insert(terminal_id, terminal); - self.state.plugin_panes.insert( - new_pane.pane_id, - crate::app::state::PluginPaneRecord { - plugin_id: plugin_id.clone(), - entrypoint: entrypoint.clone(), - }, - ); - if let Some(tab_idx) = created_tab_idx { - if let Some(tab) = self.tab_info(ws_idx, tab_idx) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabCreated, - data: crate::api::schema::EventData::TabCreated { tab }, - }); - } - } - self.schedule_session_save(); - let Some(pane) = self.pane_info(ws_idx, new_pane.pane_id) else { - return encode_error(id, "plugin_pane_open_failed", "plugin pane disappeared"); - }; - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneCreated, - data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, - }); - encode_success( - id, - ResponseResult::PluginPaneOpened { - plugin_pane: PluginPaneInfo { - plugin_id, - entrypoint, - pane, - }, - }, - ) - } - fn find_plugin_action( &self, plugin_id: Option<&str>, @@ -794,379 +553,6 @@ impl App { None } - fn merge_plugin_context( - &self, - provided: Option, - correlation_id: &str, - ) -> PluginInvocationContext { - let mut context = self.current_plugin_context(correlation_id); - if let Some(provided) = provided { - context.workspace_id = provided.workspace_id.or(context.workspace_id); - context.workspace_label = provided.workspace_label.or(context.workspace_label); - context.workspace_cwd = provided.workspace_cwd.or(context.workspace_cwd); - context.worktree = provided.worktree.or(context.worktree); - context.tab_id = provided.tab_id.or(context.tab_id); - context.tab_label = provided.tab_label.or(context.tab_label); - context.focused_pane_id = provided.focused_pane_id.or(context.focused_pane_id); - context.focused_pane_cwd = provided.focused_pane_cwd.or(context.focused_pane_cwd); - context.focused_pane_agent = provided.focused_pane_agent.or(context.focused_pane_agent); - context.focused_pane_status = - provided.focused_pane_status.or(context.focused_pane_status); - context.selected_text = provided.selected_text.or(context.selected_text); - context.invocation_source = provided.invocation_source.or(context.invocation_source); - context.correlation_id = provided.correlation_id.or(context.correlation_id); - context.clicked_url = provided.clicked_url.or(context.clicked_url); - context.link_handler_id = provided.link_handler_id.or(context.link_handler_id); - } - context - } - - fn current_plugin_context(&self, correlation_id: &str) -> PluginInvocationContext { - let Some(ws_idx) = self.state.active else { - return empty_plugin_context(correlation_id); - }; - self.plugin_context_for_workspace(ws_idx, correlation_id) - } - - fn plugin_context_for_event( - &self, - event: &crate::api::schema::EventEnvelope, - correlation_id: &str, - ) -> PluginInvocationContext { - use crate::api::schema::EventData; - - match &event.data { - EventData::WorkspaceCreated { workspace } - | EventData::WorkspaceUpdated { workspace } - | EventData::WorktreeCreated { workspace, .. } - | EventData::WorktreeOpened { workspace, .. } => { - self.plugin_context_for_workspace_info(workspace, correlation_id) - } - EventData::WorkspaceClosed { - workspace_id, - workspace, - } => workspace - .as_ref() - .map(|workspace| self.plugin_context_for_workspace_info(workspace, correlation_id)) - .unwrap_or_else(|| { - self.plugin_context_for_workspace_id(workspace_id, correlation_id) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context - }) - }), - EventData::WorkspaceRenamed { workspace_id, .. } - | EventData::WorkspaceFocused { workspace_id } - | EventData::WorktreeRemoved { workspace_id, .. } => self - .plugin_context_for_workspace_id(workspace_id, correlation_id) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context - }), - EventData::TabCreated { tab } => self.plugin_context_for_tab_info(tab, correlation_id), - EventData::TabClosed { - tab_id, - workspace_id, - } => { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context.tab_id = Some(tab_id.clone()); - context - } - EventData::TabRenamed { - tab_id, - workspace_id, - .. - } - | EventData::TabFocused { - tab_id, - workspace_id, - } => self - .plugin_context_for_tab_id(tab_id, correlation_id) - .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context.tab_id = Some(tab_id.clone()); - context - }), - EventData::PaneCreated { pane } => { - self.plugin_context_for_pane_info(pane, correlation_id) - } - EventData::PaneMoved { pane, .. } => { - self.plugin_context_for_pane_info(pane.as_ref(), correlation_id) - } - EventData::PaneClosed { - pane_id, - workspace_id, - } => { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context.focused_pane_id = Some(pane_id.clone()); - context - } - EventData::PaneFocused { - pane_id, - workspace_id, - } - | EventData::PaneOutputChanged { - pane_id, - workspace_id, - .. - } - | EventData::PaneExited { - pane_id, - workspace_id, - } - | EventData::PaneAgentDetected { - pane_id, - workspace_id, - .. - } - | EventData::PaneAgentStatusChanged { - pane_id, - workspace_id, - .. - } => self - .plugin_context_for_public_pane_id(pane_id, correlation_id) - .or_else(|| self.plugin_context_for_workspace_id(workspace_id, correlation_id)) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace_id.clone()); - context.focused_pane_id = Some(pane_id.clone()); - context - }), - } - } - - fn plugin_context_for_workspace_id( - &self, - workspace_id: &str, - correlation_id: &str, - ) -> Option { - let ws_idx = self - .state - .workspaces - .iter() - .enumerate() - .find_map(|(idx, _)| (self.public_workspace_id(idx) == workspace_id).then_some(idx))?; - Some(self.plugin_context_for_workspace(ws_idx, correlation_id)) - } - - fn plugin_context_for_workspace_info( - &self, - workspace: &crate::api::schema::WorkspaceInfo, - correlation_id: &str, - ) -> PluginInvocationContext { - self.plugin_context_for_workspace_id(&workspace.workspace_id, correlation_id) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(workspace.workspace_id.clone()); - context.workspace_label = Some(workspace.label.clone()); - context.workspace_cwd = workspace - .worktree - .as_ref() - .map(|worktree| worktree.checkout_path.clone()); - context.worktree = workspace.worktree.clone(); - context.tab_id = Some(workspace.active_tab_id.clone()); - context - }) - } - - fn plugin_context_for_tab_id( - &self, - tab_id: &str, - correlation_id: &str, - ) -> Option { - let (ws_idx, tab_idx) = self.parse_tab_id(tab_id)?; - let ws = self.state.workspaces.get(ws_idx)?; - let workspace = self.workspace_info(ws_idx); - let tab = ws.tabs.get(tab_idx)?; - let pane_id = tab.layout.focused(); - let focused_pane = self.pane_info(ws_idx, pane_id); - Some(self.plugin_context_from_parts( - ws_idx, - workspace, - self.public_tab_id(ws_idx, tab_idx), - Some(tab.display_name()), - focused_pane, - correlation_id, - )) - } - - fn plugin_context_for_tab_info( - &self, - tab: &crate::api::schema::TabInfo, - correlation_id: &str, - ) -> PluginInvocationContext { - self.plugin_context_for_tab_id(&tab.tab_id, correlation_id) - .or_else(|| self.plugin_context_for_workspace_id(&tab.workspace_id, correlation_id)) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(tab.workspace_id.clone()); - context.tab_id = Some(tab.tab_id.clone()); - context.tab_label = Some(tab.label.clone()); - context - }) - } - - fn plugin_context_for_public_pane_id( - &self, - pane_id: &str, - correlation_id: &str, - ) -> Option { - let (ws_idx, pane_id) = self.parse_pane_id(pane_id)?; - Some(self.plugin_context_for_pane(ws_idx, pane_id, correlation_id)) - } - - fn plugin_context_for_pane_info( - &self, - pane: &crate::api::schema::PaneInfo, - correlation_id: &str, - ) -> PluginInvocationContext { - self.plugin_context_for_public_pane_id(&pane.pane_id, correlation_id) - .or_else(|| self.plugin_context_for_workspace_id(&pane.workspace_id, correlation_id)) - .unwrap_or_else(|| { - let mut context = empty_plugin_context(correlation_id); - context.workspace_id = Some(pane.workspace_id.clone()); - context.tab_id = Some(pane.tab_id.clone()); - context.focused_pane_id = Some(pane.pane_id.clone()); - context.focused_pane_cwd = pane.cwd.clone(); - context.focused_pane_agent = pane.agent.clone(); - context.focused_pane_status = Some(pane.agent_status); - context - }) - } - - fn plugin_context_for_workspace( - &self, - ws_idx: usize, - correlation_id: &str, - ) -> PluginInvocationContext { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return empty_plugin_context(correlation_id); - }; - let workspace = self.workspace_info(ws_idx); - let tab_idx = ws.active_tab_index(); - let tab_id = self.public_tab_id(ws_idx, tab_idx); - let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); - let focused_pane = ws - .focused_pane_id() - .and_then(|pane_id| self.pane_info(ws_idx, pane_id)); - self.plugin_context_from_parts( - ws_idx, - workspace, - tab_id, - tab_label, - focused_pane, - correlation_id, - ) - } - - fn plugin_context_for_pane( - &self, - ws_idx: usize, - pane_id: crate::layout::PaneId, - correlation_id: &str, - ) -> PluginInvocationContext { - let ws = &self.state.workspaces[ws_idx]; - let workspace = self.workspace_info(ws_idx); - let tab_idx = ws - .find_tab_index_for_pane(pane_id) - .unwrap_or_else(|| ws.active_tab_index()); - let tab_id = self.public_tab_id(ws_idx, tab_idx); - let tab_label = ws.tabs.get(tab_idx).map(|tab| tab.display_name()); - let focused_pane = self.pane_info(ws_idx, pane_id); - self.plugin_context_from_parts( - ws_idx, - workspace, - tab_id, - tab_label, - focused_pane, - correlation_id, - ) - } - - fn plugin_context_from_parts( - &self, - ws_idx: usize, - workspace: crate::api::schema::WorkspaceInfo, - tab_id: Option, - tab_label: Option, - focused_pane: Option, - correlation_id: &str, - ) -> PluginInvocationContext { - let workspace_cwd = focused_pane - .as_ref() - .and_then(|pane| pane.cwd.clone()) - .or_else(|| Some(self.default_cwd_for_workspace(ws_idx).display().to_string())); - let selected_text = focused_pane - .as_ref() - .and_then(|pane| self.parse_pane_id(&pane.pane_id)) - .and_then(|(_, pane_id)| self.selected_text_for_pane(pane_id)); - PluginInvocationContext { - workspace_id: Some(workspace.workspace_id), - workspace_label: Some(workspace.label), - workspace_cwd, - worktree: workspace.worktree, - tab_id, - tab_label, - focused_pane_id: focused_pane.as_ref().map(|pane| pane.pane_id.clone()), - focused_pane_cwd: focused_pane.as_ref().and_then(|pane| pane.cwd.clone()), - focused_pane_agent: focused_pane.as_ref().and_then(|pane| pane.agent.clone()), - focused_pane_status: focused_pane.as_ref().map(|pane| pane.agent_status), - selected_text, - invocation_source: Some("api".to_string()), - correlation_id: Some(correlation_id.to_string()), - clicked_url: None, - link_handler_id: None, - } - } - - fn selected_text_for_pane(&self, pane_id: crate::layout::PaneId) -> Option { - let selection = self.state.selection.as_ref()?; - if selection.pane_id != pane_id || !selection.is_visible() { - return None; - } - let terminal_id = self - .state - .workspaces - .iter() - .find_map(|workspace| workspace.terminal_id(pane_id))?; - self.terminal_runtimes - .get(terminal_id) - .and_then(|runtime| runtime.extract_selection(selection)) - .filter(|text| !text.is_empty()) - } - - fn current_public_pane_id(&self) -> Option { - let ws_idx = self.state.active?; - let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; - self.public_pane_id(ws_idx, pane_id) - } - - fn default_cwd_for_workspace(&self, ws_idx: usize) -> std::path::PathBuf { - self.state - .workspaces - .get(ws_idx) - .and_then(|ws| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - }) - .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())) - } - - fn plugin_pane_cwd( - &self, - plugin: &InstalledPluginInfo, - override_cwd: Option, - ) -> std::path::PathBuf { - override_cwd - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::path::PathBuf::from(&plugin.plugin_root)) - } - fn set_plugin_enabled(&mut self, id: String, plugin_id: String, enabled: bool) -> String { let Some(plugin_id) = normalize_plugin_id(&plugin_id) else { return invalid_plugin_id(id); @@ -1192,225 +578,6 @@ impl App { } } - fn start_plugin_command( - &mut self, - plugin: &InstalledPluginInfo, - action_id: Option, - event: Option, - command: Vec, - context: &PluginInvocationContext, - event_json: Option, - ) -> Result { - let Some(program) = command.first().cloned() else { - return Err(( - "invalid_plugin_command", - "command must not be empty".to_string(), - )); - }; - let args = command.iter().skip(1).cloned().collect::>(); - let context_json = serde_json::to_string(context) - .map_err(|err| ("invalid_plugin_context", err.to_string()))?; - let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id); - self.state.next_plugin_command_log_id += 1; - let started_unix_ms = current_unix_ms(); - let mut env = vec![ - ( - crate::api::SOCKET_PATH_ENV_VAR.to_string(), - crate::api::socket_path().display().to_string(), - ), - ("HERDR_ENV".to_string(), "1".to_string()), - ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), - ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), - ]; - if let Ok(current_exe) = std::env::current_exe() { - env.push(( - "HERDR_BIN_PATH".to_string(), - current_exe.display().to_string(), - )); - } - if let Some(action_id) = action_id.as_ref() { - env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone())); - } - if let Some(event) = event.as_ref() { - env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone())); - } - if let Some(event_json) = event_json { - env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json)); - } - if let Some(workspace_id) = context.workspace_id.as_ref() { - env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone())); - } - if let Some(tab_id) = context.tab_id.as_ref() { - env.push(("HERDR_TAB_ID".to_string(), tab_id.clone())); - } - if let Some(pane_id) = context.focused_pane_id.as_ref() { - env.push(("HERDR_PANE_ID".to_string(), pane_id.clone())); - } - if let Some(clicked_url) = context.clicked_url.as_ref() { - env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone())); - } - if let Some(link_handler_id) = context.link_handler_id.as_ref() { - env.push(( - "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(), - link_handler_id.clone(), - )); - } - if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT { - let message = format!( - "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})" - ); - let log = PluginCommandLogInfo { - log_id, - plugin_id: plugin.plugin_id.clone(), - action_id, - event, - command, - status: PluginCommandStatus::Failed, - started_unix_ms, - finished_unix_ms: Some(started_unix_ms), - exit_code: None, - stdout: Some(String::new()), - stderr: Some(String::new()), - error: Some(message.clone()), - }; - self.push_plugin_command_log(log); - return Err(("plugin_command_limit_reached", message)); - } - let plugin_root = std::path::PathBuf::from(&plugin.plugin_root); - let log = PluginCommandLogInfo { - log_id: log_id.clone(), - plugin_id: plugin.plugin_id.clone(), - action_id, - event, - command: command.clone(), - status: PluginCommandStatus::Running, - started_unix_ms, - finished_unix_ms: None, - exit_code: None, - stdout: None, - stderr: None, - error: None, - }; - self.push_plugin_command_log(log.clone()); - self.state.plugin_commands_in_flight += 1; - let event_tx = self.event_tx.clone(); - std::thread::spawn(move || { - let child = Command::new(&program) - .args(args) - .current_dir(plugin_root) - .envs(env) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn(); - let finished = match child { - Ok(mut child) => { - let stdout = child.stdout.take(); - let stderr = child.stderr.take(); - let stdout_reader = stdout.map(|stdout| { - std::thread::spawn(move || { - read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) - }) - }); - let stderr_reader = stderr.map(|stderr| { - std::thread::spawn(move || { - read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) - }) - }); - match child.wait() { - Ok(status) => crate::events::AppEvent::PluginCommandFinished { - log_id, - finished_unix_ms: current_unix_ms(), - exit_code: status.code(), - stdout: stdout_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - stderr: stderr_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - error: None, - }, - Err(err) => crate::events::AppEvent::PluginCommandFinished { - log_id, - finished_unix_ms: current_unix_ms(), - exit_code: None, - stdout: stdout_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - stderr: stderr_reader - .and_then(|reader| reader.join().ok()) - .unwrap_or_default(), - error: Some(err.to_string()), - }, - } - } - Err(err) => crate::events::AppEvent::PluginCommandFinished { - log_id, - finished_unix_ms: current_unix_ms(), - exit_code: None, - stdout: String::new(), - stderr: String::new(), - error: Some(err.to_string()), - }, - }; - let _ = event_tx.blocking_send(finished); - }); - Ok(log) - } - - pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) { - let event_name = event.event.dot_name(); - if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) { - return; - } - let plugins = self - .state - .installed_plugins - .values() - .filter(|plugin| { - plugin.enabled - && plugin_manifest_available(plugin) - && plugin.events.iter().any(|hook| hook.on == event_name) - }) - .cloned() - .collect::>(); - if plugins.is_empty() { - return; - } - let event_json = serde_json::to_string(event).ok(); - let context = self.plugin_context_for_event(event, event_name); - for plugin in plugins { - for hook in plugin.events.clone() { - if hook.on != event_name { - continue; - } - if ensure_platform_supported( - &effective_platforms(&hook.platforms, &plugin.platforms).clone(), - event_name, - ) - .is_err() - { - continue; - } - let _ = self.start_plugin_command( - &plugin, - None, - Some(event_name.to_string()), - hook.command.clone(), - &context, - event_json.clone(), - ); - } - } - } - - fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) { - self.state.plugin_command_logs.push(log); - if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT { - let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT; - self.state.plugin_command_logs.drain(0..extra); - } - } - pub(crate) fn save_plugin_registry(&self) -> std::io::Result<()> { if self.no_session { return Ok(()); @@ -1425,593 +592,6 @@ impl App { } } -#[derive(serde::Deserialize)] -struct RawPluginManifest { - id: String, - name: String, - version: String, - #[serde(default)] - description: Option, - #[serde(default)] - platforms: Option>, - #[serde(default)] - build: Vec, - #[serde(default)] - actions: Vec, - #[serde(default)] - events: Vec, - #[serde(default)] - panes: Vec, - #[serde(default)] - link_handlers: Vec, -} - -#[derive(serde::Deserialize)] -struct RawPluginManifestBuild { - #[serde(default)] - platforms: Option>, - command: Vec, -} - -#[derive(serde::Deserialize)] -struct RawPluginManifestAction { - id: String, - title: String, - #[serde(default)] - description: Option, - #[serde(default)] - contexts: Vec, - #[serde(default)] - platforms: Option>, - command: Vec, -} - -#[derive(serde::Deserialize)] -struct RawPluginManifestEventHook { - on: String, - #[serde(default)] - platforms: Option>, - command: Vec, -} - -#[derive(serde::Deserialize)] -struct RawPluginManifestPane { - id: String, - title: String, - #[serde(default)] - description: Option, - #[serde(default)] - platforms: Option>, - #[serde(default)] - placement: PluginPanePlacement, - command: Vec, -} - -#[derive(serde::Deserialize)] -struct RawPluginManifestLinkHandler { - id: String, - title: String, - pattern: String, - action: String, - #[serde(default)] - platforms: Option>, -} - -/// Raw string platform value from the manifest, validated before conversion. -#[derive(serde::Deserialize)] -#[serde(try_from = "String")] -struct RawPlatform(PluginPlatform); - -impl TryFrom for RawPlatform { - type Error = String; - - fn try_from(value: String) -> Result { - match value.as_str() { - "linux" => Ok(RawPlatform(PluginPlatform::Linux)), - "macos" => Ok(RawPlatform(PluginPlatform::Macos)), - "windows" => Ok(RawPlatform(PluginPlatform::Windows)), - other => Err(format!( - "invalid_plugin_platform: unknown platform '{other}'" - )), - } - } -} - -pub(crate) fn load_plugin_manifest( - path: &str, - enabled: bool, -) -> Result { - let path = std::path::PathBuf::from(path); - let manifest_path = if path.is_dir() { - path.join("herdr-plugin.toml") - } else { - path - }; - let manifest_path = manifest_path - .canonicalize() - .map_err(|err| ("plugin_manifest_not_found", err.to_string()))?; - let plugin_root = manifest_path - .parent() - .ok_or_else(|| { - ( - "invalid_plugin_manifest_path", - "manifest path has no parent directory".to_string(), - ) - })? - .to_path_buf(); - let content = std::fs::read_to_string(&manifest_path) - .map_err(|err| ("plugin_manifest_read_failed", err.to_string()))?; - let raw: RawPluginManifest = toml::from_str(&content) - .map_err(|err| ("plugin_manifest_parse_failed", err.to_string()))?; - let plugin_id = normalize_plugin_id(&raw.id) - .ok_or_else(|| ("invalid_plugin_id", "invalid plugin id".to_string()))?; - let name = non_empty_trimmed(&raw.name, "invalid_plugin_name", "plugin name is required")?; - let version = non_empty_trimmed( - &raw.version, - "invalid_plugin_version", - "plugin version is required", - )?; - let description = raw - .description - .map(|description| description.trim().to_string()) - .filter(|description| !description.is_empty()); - let platforms = normalize_platforms(raw.platforms)?; - let build = raw - .build - .into_iter() - .map(normalize_manifest_build) - .collect::, _>>()?; - let mut actions = raw - .actions - .into_iter() - .map(normalize_manifest_action) - .collect::, _>>()?; - reject_duplicate_action_ids(&actions)?; - actions.sort_by(|a, b| a.id.cmp(&b.id)); - let mut events = raw - .events - .into_iter() - .map(normalize_manifest_event) - .collect::, _>>()?; - events.sort_by(|a, b| a.on.cmp(&b.on).then_with(|| a.command.cmp(&b.command))); - let mut panes = raw - .panes - .into_iter() - .map(normalize_manifest_pane) - .collect::, _>>()?; - reject_duplicate_pane_ids(&panes)?; - panes.sort_by(|a, b| a.id.cmp(&b.id)); - let link_handlers = raw - .link_handlers - .into_iter() - .map(normalize_manifest_link_handler) - .collect::, _>>()?; - reject_duplicate_link_handler_ids(&link_handlers)?; - validate_link_handler_actions(&link_handlers, &actions)?; - - let mut warnings = validate_event_names(&events); - if platforms.is_none() { - warnings.push("manifest does not declare platforms; platform support unknown".to_string()); - } - - Ok(InstalledPluginInfo { - plugin_id, - name, - version, - description, - manifest_path: manifest_path.display().to_string(), - plugin_root: plugin_root.display().to_string(), - enabled, - platforms, - build, - actions, - events, - panes, - link_handlers, - source: Default::default(), - warnings, - }) -} - -fn normalize_manifest_build( - build: RawPluginManifestBuild, -) -> Result { - let platforms = normalize_platforms(build.platforms)?; - let command = normalize_command(build.command)?; - Ok(PluginManifestBuild { platforms, command }) -} - -fn normalize_plugin_source( - plugin: &InstalledPluginInfo, - source: PluginSourceInfo, -) -> Result { - if source.kind == PluginSourceKind::Local { - return Ok(source); - } - let Some(managed_path) = source.managed_path.as_deref() else { - return Err(( - "invalid_plugin_source", - "GitHub plugin source requires managed_path".to_string(), - )); - }; - let managed_path = std::path::PathBuf::from(managed_path) - .canonicalize() - .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - let plugin_root = std::path::PathBuf::from(&plugin.plugin_root) - .canonicalize() - .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - let expected = crate::session::data_dir() - .join("plugins") - .join("github") - .join(crate::api::schema::plugin_managed_path_component( - &plugin.plugin_id, - )) - .canonicalize() - .map_err(|err| ("invalid_plugin_source", err.to_string()))?; - if managed_path != expected { - return Err(( - "invalid_plugin_source", - "GitHub plugin managed_path does not match the plugin id".to_string(), - )); - } - if !plugin_root.starts_with(&managed_path) { - return Err(( - "invalid_plugin_source", - "plugin manifest is not inside the managed checkout".to_string(), - )); - } - Ok(source) -} - -fn reject_duplicate_action_ids( - actions: &[PluginManifestAction], -) -> Result<(), (&'static str, String)> { - let mut seen = std::collections::HashSet::new(); - for action in actions { - if !seen.insert(action.id.as_str()) { - return Err(( - "duplicate_plugin_action_id", - format!("duplicate action id '{}'", action.id), - )); - } - } - Ok(()) -} - -fn validate_event_names(events: &[crate::api::schema::PluginManifestEventHook]) -> Vec { - let known = crate::api::schema::plugin_hook_event_names(); - events - .iter() - .filter(|hook| !known.contains(&hook.on.as_str())) - .map(|hook| format!("unknown event '{}'", hook.on)) - .collect() -} - -fn reject_duplicate_pane_ids(panes: &[PluginManifestPane]) -> Result<(), (&'static str, String)> { - let mut seen = std::collections::HashSet::new(); - for pane in panes { - if !seen.insert(pane.id.as_str()) { - return Err(( - "duplicate_plugin_pane_id", - format!("duplicate pane id '{}'", pane.id), - )); - } - } - Ok(()) -} - -fn reject_duplicate_link_handler_ids( - handlers: &[PluginManifestLinkHandler], -) -> Result<(), (&'static str, String)> { - let mut seen = std::collections::HashSet::new(); - for handler in handlers { - if !seen.insert(handler.id.as_str()) { - return Err(( - "duplicate_plugin_link_handler_id", - format!("duplicate link handler id '{}'", handler.id), - )); - } - } - Ok(()) -} - -fn validate_link_handler_actions( - handlers: &[PluginManifestLinkHandler], - actions: &[PluginManifestAction], -) -> Result<(), (&'static str, String)> { - for handler in handlers { - if !actions.iter().any(|action| action.id == handler.action) { - return Err(( - "invalid_plugin_link_handler_action", - format!( - "link handler '{}' references unknown action '{}'", - handler.id, handler.action - ), - )); - } - } - Ok(()) -} - -fn normalize_manifest_action( - action: RawPluginManifestAction, -) -> Result { - let id = normalize_action_id(&action.id) - .ok_or_else(|| ("invalid_plugin_action_id", "invalid action id".to_string()))?; - let title = non_empty_trimmed( - &action.title, - "invalid_plugin_action_title", - "action title is required", - )?; - let description = action - .description - .map(|description| description.trim().to_string()) - .filter(|description| !description.is_empty()); - let platforms = normalize_platforms(action.platforms)?; - let command = normalize_command(action.command)?; - Ok(PluginManifestAction { - id, - title, - description, - contexts: action.contexts, - platforms, - command, - }) -} - -fn normalize_manifest_pane( - pane: RawPluginManifestPane, -) -> Result { - let id = normalize_action_id(&pane.id) - .ok_or_else(|| ("invalid_plugin_pane_id", "invalid pane id".to_string()))?; - let title = non_empty_trimmed( - &pane.title, - "invalid_plugin_pane_title", - "pane title is required", - )?; - let description = pane - .description - .map(|description| description.trim().to_string()) - .filter(|description| !description.is_empty()); - let platforms = normalize_platforms(pane.platforms)?; - let command = normalize_command(pane.command)?; - Ok(PluginManifestPane { - id, - title, - description, - platforms, - placement: pane.placement, - command, - }) -} - -fn normalize_manifest_event( - event: RawPluginManifestEventHook, -) -> Result { - let on = non_empty_trimmed(&event.on, "invalid_plugin_event", "event name is required")?; - let platforms = normalize_platforms(event.platforms)?; - let command = normalize_command(event.command)?; - Ok(PluginManifestEventHook { - on, - platforms, - command, - }) -} - -fn normalize_manifest_link_handler( - handler: RawPluginManifestLinkHandler, -) -> Result { - let id = normalize_action_id(&handler.id).ok_or_else(|| { - ( - "invalid_plugin_link_handler_id", - "invalid link handler id".to_string(), - ) - })?; - let title = non_empty_trimmed( - &handler.title, - "invalid_plugin_link_handler_title", - "link handler title is required", - )?; - let pattern = non_empty_trimmed( - &handler.pattern, - "invalid_plugin_link_handler_pattern", - "link handler pattern is required", - )?; - regex::Regex::new(&pattern) - .map_err(|err| ("invalid_plugin_link_handler_pattern", err.to_string()))?; - let action = normalize_action_id(&handler.action).ok_or_else(|| { - ( - "invalid_plugin_link_handler_action", - "invalid link handler action".to_string(), - ) - })?; - let platforms = normalize_platforms(handler.platforms)?; - Ok(PluginManifestLinkHandler { - id, - title, - pattern, - action, - platforms, - }) -} - -fn normalize_platforms( - raw: Option>, -) -> Result>, (&'static str, String)> { - match raw { - None => Ok(None), - Some(list) if list.is_empty() => Err(( - "invalid_plugin_platform", - "platforms must not be an empty array; omit the field to leave platforms undeclared" - .to_string(), - )), - Some(list) => Ok(Some(list.into_iter().map(|p| p.0).collect())), - } -} - -/// Returns the platform the current binary was compiled for. -fn current_platform() -> PluginPlatform { - if cfg!(target_os = "linux") { - PluginPlatform::Linux - } else if cfg!(target_os = "macos") { - PluginPlatform::Macos - } else { - PluginPlatform::Windows - } -} - -/// Resolve the effective platforms for an action or event: use the item's own -/// platforms if declared, otherwise inherit from the plugin-level platforms. -/// Returns a reference to whichever `Option>` applies. -fn effective_platforms<'a>( - item_platforms: &'a Option>, - plugin_platforms: &'a Option>, -) -> &'a Option> { - if item_platforms.is_some() { - item_platforms - } else { - plugin_platforms - } -} - -fn ensure_platform_supported( - platforms: &Option>, - subject: &str, -) -> Result<(), (&'static str, String)> { - if let Some(platforms) = platforms { - let host = current_platform(); - if !platforms.contains(&host) { - return Err(( - "platform_unsupported", - format!( - "{subject} does not support the current platform ({})", - platform_name(host) - ), - )); - } - } - Ok(()) -} - -fn platform_name(p: PluginPlatform) -> &'static str { - match p { - PluginPlatform::Linux => "linux", - PluginPlatform::Macos => "macos", - PluginPlatform::Windows => "windows", - } -} - -fn current_unix_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0) -} - -fn empty_plugin_context(correlation_id: &str) -> PluginInvocationContext { - PluginInvocationContext { - workspace_id: None, - workspace_label: None, - workspace_cwd: None, - worktree: None, - tab_id: None, - tab_label: None, - focused_pane_id: None, - focused_pane_cwd: None, - focused_pane_agent: None, - focused_pane_status: None, - selected_text: None, - invocation_source: Some("api".to_string()), - correlation_id: Some(correlation_id.to_string()), - clicked_url: None, - link_handler_id: None, - } -} - -fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String { - let mut kept = Vec::with_capacity(cap.min(8192)); - let mut buf = [0u8; 8192]; - let mut truncated = false; - loop { - match reader.read(&mut buf) { - Ok(0) => break, - Ok(n) => { - let remaining = cap.saturating_sub(kept.len()); - if remaining > 0 { - kept.extend_from_slice(&buf[..n.min(remaining)]); - } - if n > remaining { - truncated = true; - } - } - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, - } - } - let mut output = String::from_utf8_lossy(&kept).into_owned(); - if truncated { - output.push_str(&format!( - "\n[herdr truncated plugin output after {cap} bytes]" - )); - } - output -} - -fn normalize_command(command: Vec) -> Result, (&'static str, String)> { - let command = command - .into_iter() - .map(|arg| arg.trim().to_string()) - .collect::>(); - if command.is_empty() || command.iter().any(|arg| arg.is_empty()) { - return Err(( - "invalid_plugin_command", - "command must contain non-empty argv strings".to_string(), - )); - } - Ok(command) -} - -fn non_empty_trimmed( - value: &str, - code: &'static str, - message: &'static str, -) -> Result { - let value = value.trim().to_string(); - if value.is_empty() { - Err((code, message.to_string())) - } else { - Ok(value) - } -} - -fn normalize_plugin_id(value: &str) -> Option { - normalize_identifier(value, PLUGIN_ID_MAX_CHARS) -} - -fn normalize_action_id(value: &str) -> Option { - normalize_local_identifier(value, PLUGIN_ACTION_ID_MAX_CHARS) -} - -fn normalize_identifier(value: &str, max_chars: usize) -> Option { - let value = value.trim(); - (!value.is_empty() - && value.chars().count() <= max_chars - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'.' | b'_' | b'-'))) - .then(|| value.to_string()) -} - -fn normalize_local_identifier(value: &str, max_chars: usize) -> Option { - let value = value.trim(); - (!value.is_empty() - && value.chars().count() <= max_chars - && value - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-'))) - .then(|| value.to_string()) -} - fn invalid_plugin_id(id: String) -> String { encode_error( id, @@ -2041,16 +621,6 @@ fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool { }) } -fn plugin_pane_protected_env_key(key: &str) -> bool { - matches!( - key, - "HERDR_PLUGIN_ID" - | "HERDR_PLUGIN_ENTRYPOINT_ID" - | "HERDR_PLUGIN_CONTEXT_JSON" - | "HERDR_BIN_PATH" - ) -} - fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> PluginActionInfo { PluginActionInfo { plugin_id: plugin_id.to_string(), @@ -2080,7 +650,9 @@ fn manifest_actions( #[cfg(test)] mod tests { use super::*; - use crate::api::schema::{Method, Request, SuccessResponse}; + use crate::api::schema::{ + Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse, + }; use std::time::{SystemTime, UNIX_EPOCH}; fn test_app() -> App { diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs new file mode 100644 index 00000000..f8d8ca3d --- /dev/null +++ b/src/app/api/plugins/panes.rs @@ -0,0 +1,280 @@ +use ratatui::layout::Direction; + +use super::super::responses::{encode_error, encode_success}; +use crate::api::schema::{ + InstalledPluginInfo, PluginInvocationContext, PluginManifestPane, PluginPaneInfo, + PluginPaneOpenParams, PluginPanePlacement, ResponseResult, +}; +use crate::app::App; + +impl App { + pub(super) fn open_plugin_overlay_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let context = self.current_plugin_context("plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let (ws_idx, new_pane) = + match self.spawn_overlay_argv_command(&pane.command, cwd, extra_env, Vec::new()) { + Ok(result) => result, + Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + }; + self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + } + + pub(super) fn open_plugin_split_pane( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + placement: PluginPanePlacement, + ) -> String { + let target_pane_id = params + .target_pane_id + .clone() + .or_else(|| self.current_public_pane_id()); + let Some(target_pane_id) = target_pane_id else { + return encode_error(id, "no_active_pane", "no active pane"); + }; + let Some((ws_idx, target_pane)) = self.parse_pane_id(&target_pane_id) else { + return encode_error( + id, + "pane_not_found", + format!("pane {target_pane_id} not found"), + ); + }; + let context = self.plugin_context_for_pane(ws_idx, target_pane, "plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let direction = match params + .direction + .unwrap_or(crate::api::schema::SplitDirection::Right) + { + crate::api::schema::SplitDirection::Right => Direction::Horizontal, + crate::api::schema::SplitDirection::Down => Direction::Vertical, + }; + let cwd = Some(self.plugin_pane_cwd(plugin, params.cwd)); + let (rows, cols) = self.state.estimate_pane_size(); + let previous_focus = self.state.current_pane_focus_target(); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + let result = ws.split_pane_argv_command( + target_pane, + direction, + rows.max(4), + cols.max(10), + cwd, + &pane.command, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + params.focus || placement == PluginPanePlacement::Zoomed, + ); + let (tab_idx, new_pane) = match result { + Some(Ok(result)) => result, + Some(Err(err)) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + None => { + return encode_error( + id, + "pane_not_found", + format!("pane {target_pane_id} not found"), + ) + } + }; + if params.focus || placement == PluginPanePlacement::Zoomed { + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state + .record_pane_focus_change(previous_focus, ws_idx, new_pane.pane_id); + self.state.mode = crate::app::Mode::Terminal; + } + if placement == PluginPanePlacement::Zoomed { + if let Some(tab) = self + .state + .workspaces + .get_mut(ws_idx) + .and_then(|ws| ws.tabs.get_mut(tab_idx)) + { + tab.zoomed = true; + } + } + self.finish_plugin_pane_open(id, ws_idx, None, new_pane, plugin.plugin_id.clone(), pane) + } + + pub(super) fn open_plugin_tab( + &mut self, + id: String, + params: PluginPaneOpenParams, + plugin: &InstalledPluginInfo, + pane: PluginManifestPane, + ) -> String { + let ws_idx = match params.workspace_id.as_deref() { + Some(workspace_id) => match self.parse_workspace_id(workspace_id) { + Some(ws_idx) => ws_idx, + None => return encode_error(id, "workspace_not_found", "workspace not found"), + }, + None => match self.state.active { + Some(ws_idx) => ws_idx, + None => return encode_error(id, "no_active_workspace", "no active workspace"), + }, + }; + let cwd = self.plugin_pane_cwd(plugin, params.cwd); + let context = self.plugin_context_for_workspace(ws_idx, "plugin-pane"); + let extra_env = + match self.plugin_pane_launch_env(plugin, &pane.id, params.env.clone(), &context) { + Ok(env) => env, + Err((code, message)) => return encode_error(id, &code, message), + }; + let (rows, cols) = self.state.estimate_pane_size(); + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return encode_error(id, "workspace_not_found", "workspace not found"); + }; + let (tab_idx, terminal, runtime) = match ws.create_tab_argv_command( + rows.max(4), + cols.max(10), + cwd, + &pane.command, + extra_env, + self.state.pane_scrollback_limit_bytes, + self.state.host_terminal_theme, + ) { + Ok(result) => result, + Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()), + }; + let pane_id = ws.tabs[tab_idx].root_pane; + if params.focus { + self.state.switch_workspace_tab(ws_idx, tab_idx); + self.state.mode = crate::app::Mode::Terminal; + } + let new_pane = crate::workspace::NewPane { + pane_id, + terminal, + runtime, + }; + self.finish_plugin_pane_open( + id, + ws_idx, + Some(tab_idx), + new_pane, + plugin.plugin_id.clone(), + pane, + ) + } + + fn plugin_pane_launch_env( + &self, + plugin: &InstalledPluginInfo, + entrypoint: &str, + env: std::collections::HashMap, + context: &PluginInvocationContext, + ) -> Result, (String, String)> { + let mut env = super::super::env::normalize_launch_env(env)?; + let context_json = serde_json::to_string(&context) + .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?; + env.retain(|(key, _)| !plugin_pane_protected_env_key(key)); + env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone())); + env.push(( + "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), + entrypoint.to_string(), + )); + env.push(("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json)); + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + Ok(env) + } + + fn finish_plugin_pane_open( + &mut self, + id: String, + ws_idx: usize, + created_tab_idx: Option, + new_pane: crate::workspace::NewPane, + plugin_id: String, + pane_manifest: PluginManifestPane, + ) -> String { + let entrypoint = pane_manifest.id.clone(); + let mut terminal = new_pane.terminal; + terminal.set_manual_label(pane_manifest.title.clone()); + let terminal_id = terminal.id.clone(); + self.terminal_runtimes + .insert(terminal_id.clone(), new_pane.runtime); + self.state + .remove_alias_shadowed_by_new_pane(new_pane.pane_id); + self.state.terminals.insert(terminal_id, terminal); + self.state.plugin_panes.insert( + new_pane.pane_id, + crate::app::state::PluginPaneRecord { + plugin_id: plugin_id.clone(), + entrypoint: entrypoint.clone(), + }, + ); + if let Some(tab_idx) = created_tab_idx { + if let Some(tab) = self.tab_info(ws_idx, tab_idx) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabCreated, + data: crate::api::schema::EventData::TabCreated { tab }, + }); + } + } + self.schedule_session_save(); + let Some(pane) = self.pane_info(ws_idx, new_pane.pane_id) else { + return encode_error(id, "plugin_pane_open_failed", "plugin pane disappeared"); + }; + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneCreated, + data: crate::api::schema::EventData::PaneCreated { pane: pane.clone() }, + }); + encode_success( + id, + ResponseResult::PluginPaneOpened { + plugin_pane: PluginPaneInfo { + plugin_id, + entrypoint, + pane, + }, + }, + ) + } + + fn plugin_pane_cwd( + &self, + plugin: &InstalledPluginInfo, + override_cwd: Option, + ) -> std::path::PathBuf { + override_cwd + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(&plugin.plugin_root)) + } + + fn current_public_pane_id(&self) -> Option { + let ws_idx = self.state.active?; + let pane_id = self.state.workspaces.get(ws_idx)?.focused_pane_id()?; + self.public_pane_id(ws_idx, pane_id) + } +} + +fn plugin_pane_protected_env_key(key: &str) -> bool { + matches!( + key, + "HERDR_PLUGIN_ID" + | "HERDR_PLUGIN_ENTRYPOINT_ID" + | "HERDR_PLUGIN_CONTEXT_JSON" + | "HERDR_BIN_PATH" + ) +} diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs new file mode 100644 index 00000000..d3f3eafa --- /dev/null +++ b/src/app/api/plugins/runtime.rs @@ -0,0 +1,270 @@ +use std::io::Read; +use std::process::{Command, Stdio}; + +use super::manifest::{effective_platforms, ensure_platform_supported}; +use super::plugin_manifest_available; +use crate::api::schema::{ + InstalledPluginInfo, PluginCommandLogInfo, PluginCommandStatus, PluginInvocationContext, +}; +use crate::app::App; + +const PLUGIN_COMMAND_OUTPUT_MAX_BYTES: usize = 64 * 1024; +pub(super) const MAX_PLUGIN_COMMANDS_IN_FLIGHT: usize = 32; +const PLUGIN_COMMAND_LOG_LIMIT: usize = 200; + +impl App { + pub(super) fn start_plugin_command( + &mut self, + plugin: &InstalledPluginInfo, + action_id: Option, + event: Option, + command: Vec, + context: &PluginInvocationContext, + event_json: Option, + ) -> Result { + let Some(program) = command.first().cloned() else { + return Err(( + "invalid_plugin_command", + "command must not be empty".to_string(), + )); + }; + let args = command.iter().skip(1).cloned().collect::>(); + let context_json = serde_json::to_string(context) + .map_err(|err| ("invalid_plugin_context", err.to_string()))?; + let log_id = format!("plugin-log-{}", self.state.next_plugin_command_log_id); + self.state.next_plugin_command_log_id += 1; + let started_unix_ms = current_unix_ms(); + let mut env = vec![ + ( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + ), + ("HERDR_ENV".to_string(), "1".to_string()), + ("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone()), + ("HERDR_PLUGIN_CONTEXT_JSON".to_string(), context_json), + ]; + if let Ok(current_exe) = std::env::current_exe() { + env.push(( + "HERDR_BIN_PATH".to_string(), + current_exe.display().to_string(), + )); + } + if let Some(action_id) = action_id.as_ref() { + env.push(("HERDR_PLUGIN_ACTION_ID".to_string(), action_id.clone())); + } + if let Some(event) = event.as_ref() { + env.push(("HERDR_PLUGIN_EVENT".to_string(), event.clone())); + } + if let Some(event_json) = event_json { + env.push(("HERDR_PLUGIN_EVENT_JSON".to_string(), event_json)); + } + if let Some(workspace_id) = context.workspace_id.as_ref() { + env.push(("HERDR_WORKSPACE_ID".to_string(), workspace_id.clone())); + } + if let Some(tab_id) = context.tab_id.as_ref() { + env.push(("HERDR_TAB_ID".to_string(), tab_id.clone())); + } + if let Some(pane_id) = context.focused_pane_id.as_ref() { + env.push(("HERDR_PANE_ID".to_string(), pane_id.clone())); + } + if let Some(clicked_url) = context.clicked_url.as_ref() { + env.push(("HERDR_PLUGIN_CLICKED_URL".to_string(), clicked_url.clone())); + } + if let Some(link_handler_id) = context.link_handler_id.as_ref() { + env.push(( + "HERDR_PLUGIN_LINK_HANDLER_ID".to_string(), + link_handler_id.clone(), + )); + } + if self.state.plugin_commands_in_flight >= MAX_PLUGIN_COMMANDS_IN_FLIGHT { + let message = format!( + "maximum concurrent plugin commands reached ({MAX_PLUGIN_COMMANDS_IN_FLIGHT})" + ); + let log = PluginCommandLogInfo { + log_id, + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command, + status: PluginCommandStatus::Failed, + started_unix_ms, + finished_unix_ms: Some(started_unix_ms), + exit_code: None, + stdout: Some(String::new()), + stderr: Some(String::new()), + error: Some(message.clone()), + }; + self.push_plugin_command_log(log); + return Err(("plugin_command_limit_reached", message)); + } + let plugin_root = std::path::PathBuf::from(&plugin.plugin_root); + let log = PluginCommandLogInfo { + log_id: log_id.clone(), + plugin_id: plugin.plugin_id.clone(), + action_id, + event, + command: command.clone(), + status: PluginCommandStatus::Running, + started_unix_ms, + finished_unix_ms: None, + exit_code: None, + stdout: None, + stderr: None, + error: None, + }; + self.push_plugin_command_log(log.clone()); + self.state.plugin_commands_in_flight += 1; + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let child = Command::new(&program) + .args(args) + .current_dir(plugin_root) + .envs(env) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + let finished = match child { + Ok(mut child) => { + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = stdout.map(|stdout| { + std::thread::spawn(move || { + read_capped_plugin_output(stdout, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + let stderr_reader = stderr.map(|stderr| { + std::thread::spawn(move || { + read_capped_plugin_output(stderr, PLUGIN_COMMAND_OUTPUT_MAX_BYTES) + }) + }); + match child.wait() { + Ok(status) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: status.code(), + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: None, + }, + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: stdout_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + stderr: stderr_reader + .and_then(|reader| reader.join().ok()) + .unwrap_or_default(), + error: Some(err.to_string()), + }, + } + } + Err(err) => crate::events::AppEvent::PluginCommandFinished { + log_id, + finished_unix_ms: current_unix_ms(), + exit_code: None, + stdout: String::new(), + stderr: String::new(), + error: Some(err.to_string()), + }, + }; + let _ = event_tx.blocking_send(finished); + }); + Ok(log) + } + + pub(crate) fn run_plugin_event_hooks(&mut self, event: &crate::api::schema::EventEnvelope) { + let event_name = event.event.dot_name(); + if !crate::api::schema::PLUGIN_HOOK_EVENT_KINDS.contains(&event.event) { + return; + } + let plugins = self + .state + .installed_plugins + .values() + .filter(|plugin| { + plugin.enabled + && plugin_manifest_available(plugin) + && plugin.events.iter().any(|hook| hook.on == event_name) + }) + .cloned() + .collect::>(); + if plugins.is_empty() { + return; + } + let event_json = serde_json::to_string(event).ok(); + let context = self.plugin_context_for_event(event, event_name); + for plugin in plugins { + for hook in plugin.events.clone() { + if hook.on != event_name { + continue; + } + if ensure_platform_supported( + &effective_platforms(&hook.platforms, &plugin.platforms).clone(), + event_name, + ) + .is_err() + { + continue; + } + let _ = self.start_plugin_command( + &plugin, + None, + Some(event_name.to_string()), + hook.command.clone(), + &context, + event_json.clone(), + ); + } + } + } + + fn push_plugin_command_log(&mut self, log: PluginCommandLogInfo) { + self.state.plugin_command_logs.push(log); + if self.state.plugin_command_logs.len() > PLUGIN_COMMAND_LOG_LIMIT { + let extra = self.state.plugin_command_logs.len() - PLUGIN_COMMAND_LOG_LIMIT; + self.state.plugin_command_logs.drain(0..extra); + } + } +} + +fn current_unix_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +pub(super) fn read_capped_plugin_output(mut reader: impl Read, cap: usize) -> String { + let mut kept = Vec::with_capacity(cap.min(8192)); + let mut buf = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let remaining = cap.saturating_sub(kept.len()); + if remaining > 0 { + kept.extend_from_slice(&buf[..n.min(remaining)]); + } + if n > remaining { + truncated = true; + } + } + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + } + let mut output = String::from_utf8_lossy(&kept).into_owned(); + if truncated { + output.push_str(&format!( + "\n[herdr truncated plugin output after {cap} bytes]" + )); + } + output +}