aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 20:45:50 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-14 20:45:59 +0300
commitd3be4f8b431587b14975d0e595e4131e3c119bdb (patch)
tree80bbbbf6c0ab295acb67cf6bdb05cc75f435e3f5 /src
parent54b58bb3452bd493a2881623a01371705ff806f1 (diff)
fix: add best-effort windows plugin support
Diffstat (limited to 'src')
-rw-r--r--src/api/schema/plugins.rs107
-rw-r--r--src/app/api/plugins/mod.rs20
-rw-r--r--src/app/api/plugins/panes.rs9
-rw-r--r--src/app/api/plugins/runtime.rs5
-rw-r--r--src/cli/plugin.rs27
-rw-r--r--src/main.rs1
-rw-r--r--src/persist/plugin_registry.rs18
-rw-r--r--src/plugin_command.rs129
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"));
+ }
+}