diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 20:45:50 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-14 20:45:59 +0300 |
| commit | d3be4f8b431587b14975d0e595e4131e3c119bdb (patch) | |
| tree | 80bbbbf6c0ab295acb67cf6bdb05cc75f435e3f5 /src | |
| parent | 54b58bb3452bd493a2881623a01371705ff806f1 (diff) | |
fix: add best-effort windows plugin support
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema/plugins.rs | 107 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 20 | ||||
| -rw-r--r-- | src/app/api/plugins/panes.rs | 9 | ||||
| -rw-r--r-- | src/app/api/plugins/runtime.rs | 5 | ||||
| -rw-r--r-- | src/cli/plugin.rs | 27 | ||||
| -rw-r--r-- | src/main.rs | 1 | ||||
| -rw-r--r-- | src/persist/plugin_registry.rs | 18 | ||||
| -rw-r--r-- | src/plugin_command.rs | 129 |
8 files changed, 300 insertions, 16 deletions
diff --git a/src/api/schema/plugins.rs b/src/api/schema/plugins.rs index 535733ec..be9336c8 100644 --- a/src/api/schema/plugins.rs +++ b/src/api/schema/plugins.rs @@ -106,16 +106,117 @@ pub enum PluginSourceKind { } pub(crate) fn plugin_managed_path_component(value: &str) -> String { - value + let slug = readable_plugin_path_slug(value); + let hash = short_plugin_id_hash(value); + format!("{slug}-{hash}") +} + +fn readable_plugin_path_slug(value: &str) -> String { + let mut slug = value .chars() .map(|ch| { if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { ch } else { - '_' + '-' } }) - .collect() + .collect::<String>(); + while slug.contains("--") { + slug = slug.replace("--", "-"); + } + let slug = slug + .trim_matches(|ch| matches!(ch, '-' | '_' | '.')) + .to_string(); + let slug = if slug.is_empty() { + "plugin".to_string() + } else { + slug.chars().take(80).collect() + }; + if has_windows_reserved_stem(&slug) { + slug.replace('.', "-") + } else { + slug + } +} + +fn short_plugin_id_hash(value: &str) -> String { + use sha2::{Digest, Sha256}; + + let digest = Sha256::digest(value.as_bytes()); + let mut hash = String::with_capacity(12); + for byte in &digest[..6] { + use std::fmt::Write as _; + let _ = write!(hash, "{byte:02x}"); + } + hash +} + +fn has_windows_reserved_stem(value: &str) -> bool { + let stem = value.split('.').next().unwrap_or(value); + matches!( + stem.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plugin_managed_path_component_is_windows_safe_and_collision_free() { + let dotdot = plugin_managed_path_component(".."); + assert_ne!(dotdot, "."); + assert_ne!(dotdot, ".."); + assert!(dotdot.starts_with("plugin-")); + assert_ne!( + plugin_managed_path_component("a:b"), + plugin_managed_path_component("a_b") + ); + assert_ne!(plugin_managed_path_component("con"), "con"); + assert!(!plugin_managed_path_component("example.").ends_with('.')); + assert!(plugin_managed_path_component("con.example").starts_with("con-example-")); + assert!(plugin_managed_path_component("aux.plugin").starts_with("aux-plugin-")); + assert!(plugin_managed_path_component("nul.x").starts_with("nul-x-")); + assert!(plugin_managed_path_component("com1.tool").starts_with("com1-tool-")); + } + + #[test] + fn plugin_managed_path_component_keeps_readable_slug() { + let component = plugin_managed_path_component("example.worktree-bootstrap"); + assert!(component.starts_with("example.worktree-bootstrap-")); + assert!(component.len() <= "example.worktree-bootstrap-".len() + 12); + } + + #[test] + fn plugin_managed_path_component_hash_distinguishes_same_slug_shape() { + assert_ne!( + plugin_managed_path_component("example:a"), + plugin_managed_path_component("example/a") + ); + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 6cc266e3..d00ad20f 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -471,7 +471,7 @@ impl App { .actions .iter() .find(|a| a.id == action_id) - .map(|a| manifest_action_info(&plugin_id, a)) + .map(|a| manifest_action_info(&plugin_id, &plugin.platforms, a)) .ok_or_else(|| { ( "plugin_action_not_found", @@ -622,7 +622,11 @@ fn plugin_manifest_available(plugin: &InstalledPluginInfo) -> bool { }) } -fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> PluginActionInfo { +fn manifest_action_info( + plugin_id: &str, + plugin_platforms: &Option<Vec<crate::api::schema::PluginPlatform>>, + action: &PluginManifestAction, +) -> PluginActionInfo { PluginActionInfo { plugin_id: plugin_id.to_string(), action_id: action.id.clone(), @@ -630,7 +634,7 @@ fn manifest_action_info(plugin_id: &str, action: &PluginManifestAction) -> Plugi description: action.description.clone(), contexts: action.contexts.clone(), command: action.command.clone(), - platforms: action.platforms.clone(), + platforms: effective_platforms(&action.platforms, plugin_platforms).clone(), } } @@ -644,7 +648,7 @@ fn manifest_actions( plugin .actions .iter() - .map(|action| manifest_action_info(&plugin.plugin_id, action)) + .map(|action| manifest_action_info(&plugin.plugin_id, &plugin.platforms, action)) }) } @@ -1311,6 +1315,14 @@ command = ["sh", "-c", "sleep 1"] "example.worktree-bootstrap.bootstrap" ); assert_eq!(actions[0].command, ["bun", "run", "bootstrap.ts"]); + assert_eq!( + actions[0].platforms, + Some(vec![ + crate::api::schema::PluginPlatform::Linux, + crate::api::schema::PluginPlatform::Macos, + crate::api::schema::PluginPlatform::Windows, + ]) + ); let invoke = app.handle_api_request(Request { id: "invoke".into(), diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index 22173cbf..e6fe7b44 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -185,6 +185,11 @@ impl App { .map_err(|err| ("invalid_plugin_context".to_string(), err.to_string()))?; env.retain(|(key, _)| !plugin_pane_protected_env_key(key)); env.extend(super::env::plugin_path_env(plugin)); + env.push(( + crate::api::SOCKET_PATH_ENV_VAR.to_string(), + crate::api::socket_path().display().to_string(), + )); + env.push(("HERDR_ENV".to_string(), "1".to_string())); env.push(("HERDR_PLUGIN_ID".to_string(), plugin.plugin_id.clone())); env.push(( "HERDR_PLUGIN_ENTRYPOINT_ID".to_string(), @@ -273,7 +278,9 @@ impl App { fn plugin_pane_protected_env_key(key: &str) -> bool { matches!( key, - "HERDR_PLUGIN_ID" + crate::api::SOCKET_PATH_ENV_VAR + | "HERDR_ENV" + | "HERDR_PLUGIN_ID" | "HERDR_PLUGIN_ROOT" | "HERDR_PLUGIN_CONFIG_DIR" | "HERDR_PLUGIN_STATE_DIR" diff --git a/src/app/api/plugins/runtime.rs b/src/app/api/plugins/runtime.rs index c6955bb4..94a49dd4 100644 --- a/src/app/api/plugins/runtime.rs +++ b/src/app/api/plugins/runtime.rs @@ -1,5 +1,5 @@ use std::io::Read; -use std::process::{Command, Stdio}; +use std::process::Stdio; use super::manifest::{effective_platforms, ensure_platform_supported}; use super::plugin_manifest_available; @@ -117,8 +117,7 @@ impl App { 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) + let child = crate::plugin_command::command_for_argv(&program, &args) .current_dir(plugin_root) .envs(env) .stdout(Stdio::piped()) diff --git a/src/cli/plugin.rs b/src/cli/plugin.rs index 081a27bd..f391fa47 100644 --- a/src/cli/plugin.rs +++ b/src/cli/plugin.rs @@ -192,14 +192,17 @@ fn plugin_install(args: &[String]) -> std::io::Result<i32> { let backup_checkout = temp_root.join("previous-checkout"); let mut backup_moved = false; if final_checkout.exists() { - std::fs::rename(&final_checkout, &backup_checkout)?; + std::fs::rename(&final_checkout, &backup_checkout) + .map_err(|err| plugin_checkout_lifecycle_error("replace", &final_checkout, err))?; backup_moved = true; } let install_attempt = (|| { if let Some(parent) = final_checkout.parent() { std::fs::create_dir_all(parent).map_err(InstallFailure::Rollback)?; } - std::fs::rename(&checkout, &final_checkout).map_err(InstallFailure::Rollback)?; + std::fs::rename(&checkout, &final_checkout) + .map_err(|err| plugin_checkout_lifecycle_error("install", &final_checkout, err)) + .map_err(InstallFailure::Rollback)?; source_info.managed_path = Some(final_checkout.display().to_string()); let final_manifest_root = source.manifest_root(&final_checkout); @@ -1220,9 +1223,9 @@ fn run_plugin_build_command( }, })); }; - let mut child = Command::new(program); + let args = command.iter().skip(1).cloned().collect::<Vec<_>>(); + let mut child = crate::plugin_command::command_for_argv(program, &args); child - .args(command.iter().skip(1)) .current_dir(cwd) .stdin(Stdio::null()) .stdout(Stdio::piped()) @@ -1499,7 +1502,21 @@ fn remove_managed_plugin_files(plugin: &InstalledPluginInfo) -> std::io::Result< path.display() ))); } - std::fs::remove_dir_all(path) + std::fs::remove_dir_all(&path) + .map_err(|err| plugin_checkout_lifecycle_error("remove", &path, err)) +} + +fn plugin_checkout_lifecycle_error(operation: &str, path: &Path, err: io::Error) -> io::Error { + if cfg!(windows) && err.kind() == io::ErrorKind::PermissionDenied { + return io::Error::new( + err.kind(), + format!( + "failed to {operation} managed plugin checkout at {}; close any Herdr plugin panes or plugin commands using that checkout, then retry: {err}", + path.display() + ), + ); + } + err } fn is_expected_managed_path(plugin: &InstalledPluginInfo, path: &Path) -> bool { diff --git a/src/main.rs b/src/main.rs index d2beb114..d2efcc90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -64,6 +64,7 @@ mod logging; mod pane; mod persist; mod platform; +mod plugin_command; mod product_announcements; mod protocol; mod pty; diff --git a/src/persist/plugin_registry.rs b/src/persist/plugin_registry.rs index 19db842a..b2eaac9f 100644 --- a/src/persist/plugin_registry.rs +++ b/src/persist/plugin_registry.rs @@ -17,6 +17,13 @@ fn save_json_to_path<T: serde::Serialize + ?Sized>(path: &Path, value: &T) -> st let json = serde_json::to_string_pretty(value)?; let tmp_path = path.with_extension("json.tmp"); std::fs::write(&tmp_path, &json)?; + #[cfg(windows)] + if path.exists() { + if let Err(err) = std::fs::remove_file(path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err); + } + } if let Err(err) = std::fs::rename(&tmp_path, path) { let _ = std::fs::remove_file(&tmp_path); return Err(err); @@ -234,4 +241,15 @@ mod tests { ); assert!(path.exists()); } + + #[test] + fn save_replaces_existing_registry_file() { + let path = temp_registry_path("replace-existing"); + save_to_path(&path, &[sample_plugin("example.first")]).unwrap(); + save_to_path(&path, &[sample_plugin("example.second")]).unwrap(); + + let loaded = load_from_path(&path); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].plugin_id, "example.second"); + } } diff --git a/src/plugin_command.rs b/src/plugin_command.rs new file mode 100644 index 00000000..ba0a1422 --- /dev/null +++ b/src/plugin_command.rs @@ -0,0 +1,129 @@ +#[cfg(any(windows, test))] +use std::path::Path; +#[cfg(windows)] +use std::path::PathBuf; +use std::process::Command; + +pub(crate) fn command_for_argv(program: &str, args: &[String]) -> Command { + let mut command = command_for_program(program); + command.args(args); + command +} + +#[cfg(not(windows))] +fn command_for_program(program: &str) -> Command { + Command::new(program) +} + +#[cfg(windows)] +fn command_for_program(program: &str) -> Command { + let resolved = resolve_windows_program(program); + let command_program = resolved.as_ref().map_or_else( + || std::ffi::OsString::from(program), + |path| path.as_os_str().to_os_string(), + ); + if is_windows_batch_file_name(program) + || resolved + .as_ref() + .is_some_and(|path| is_windows_batch_path(path)) + { + let shell = + std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); + let mut command = Command::new(shell); + command.arg("/d").arg("/c").arg(command_program); + command + } else { + Command::new(command_program) + } +} + +#[cfg(windows)] +fn resolve_windows_program(program: &str) -> Option<PathBuf> { + if has_path_separator(program) { + return None; + } + let path = Path::new(program); + if path.extension().is_some() { + return std::env::var_os("PATH").and_then(|path_var| { + std::env::split_paths(&path_var) + .map(|dir| dir.join(program)) + .find(|candidate| candidate.is_file()) + }); + } + let extensions = windows_path_extensions(); + std::env::var_os("PATH").and_then(|path_var| { + std::env::split_paths(&path_var).find_map(|dir| { + extensions + .iter() + .map(|extension| dir.join(format!("{program}{extension}"))) + .find(|candidate| candidate.is_file()) + }) + }) +} + +#[cfg(windows)] +fn windows_path_extensions() -> Vec<String> { + std::env::var_os("PATHEXT") + .map(|value| { + value + .to_string_lossy() + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| { + if part.starts_with('.') { + part.to_string() + } else { + format!(".{part}") + } + }) + .collect::<Vec<_>>() + }) + .filter(|extensions| !extensions.is_empty()) + .unwrap_or_else(|| { + vec![ + ".COM".to_string(), + ".EXE".to_string(), + ".BAT".to_string(), + ".CMD".to_string(), + ] + }) +} + +#[cfg(windows)] +fn has_path_separator(program: &str) -> bool { + program.contains(['/', '\\']) +} + +#[cfg(windows)] +fn is_windows_batch_path(path: &Path) -> bool { + path.extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(is_windows_batch_extension) +} + +#[cfg(any(windows, test))] +fn is_windows_batch_file_name(program: &str) -> bool { + Path::new(program) + .extension() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(is_windows_batch_extension) +} + +#[cfg(any(windows, test))] +fn is_windows_batch_extension(extension: &str) -> bool { + extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recognizes_windows_batch_extensions_case_insensitively() { + assert!(is_windows_batch_file_name("npm.cmd")); + assert!(is_windows_batch_file_name("script.BAT")); + assert!(!is_windows_batch_file_name("node.exe")); + assert!(!is_windows_batch_file_name("node")); + } +} |
