diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-05 21:56:17 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-05 21:56:17 +0200 |
| commit | 6f57f2f62c5d2591647262caf15bc3c962b9c9f8 (patch) | |
| tree | aeece6f488a6ec55e15369151fed74f0137a69bb /src | |
| parent | 50ce4ec2ddb8ec621a77d8773aa6df6d9e7441ff (diff) | |
fix: resolve Windows plugin pane launch paths (#3666)
* fix: resolve Windows plugin pane launch paths
refs #3024
* test: compare resolved Windows plugin probe paths
refs #3024
* test: compare resolved Windows plugin working directories
refs #3024
* test: wait for Windows plugin fixture completion
refs #3024
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api/plugins/manifest.rs | 8 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 187 | ||||
| -rw-r--r-- | src/platform/mod.rs | 9 | ||||
| -rw-r--r-- | src/platform/windows.rs | 107 | ||||
| -rw-r--r-- | src/plugin_command.rs | 60 |
5 files changed, 336 insertions, 35 deletions
diff --git a/src/app/api/plugins/manifest.rs b/src/app/api/plugins/manifest.rs index 657f8257..fb650065 100644 --- a/src/app/api/plugins/manifest.rs +++ b/src/app/api/plugins/manifest.rs @@ -128,15 +128,13 @@ pub(crate) fn load_plugin_manifest( 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(|| { + let plugin_root = + crate::platform::plugin_runtime_path(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) diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 056315c3..f4b6329b 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -440,7 +440,7 @@ impl App { let Some(entrypoint) = normalize_action_id(¶ms.entrypoint) else { return encode_error(id, "invalid_plugin_entrypoint", "invalid entrypoint id"); }; - let Some(pane) = plugin + let Some(mut pane) = plugin .panes .iter() .find(|pane| pane.id == entrypoint) @@ -458,6 +458,12 @@ impl App { ) { return encode_error(id, code, message); } + pane.command[0] = crate::plugin_command::program_for_cwd( + &pane.command[0], + std::path::Path::new(&plugin.plugin_root), + ) + .display() + .to_string(); let placement = params.placement.unwrap_or(pane.placement); if placement != PluginPanePlacement::Popup && (params.width.is_some() || params.height.is_some()) @@ -987,7 +993,16 @@ platforms = ["linux", "macos", "windows"] assert_eq!(plugin.plugin_id, "example.worktree-bootstrap"); assert_eq!(plugin.name, "Worktree Bootstrap"); assert_eq!(plugin.version, "0.1.0"); - assert_eq!(plugin.plugin_root, canonical_path_string(&root)); + assert_eq!( + plugin.manifest_path, + canonical_path_string(&root.join("herdr-plugin.toml")) + ); + assert_eq!( + plugin.plugin_root, + crate::platform::plugin_runtime_path(&root.canonicalize().unwrap()) + .display() + .to_string() + ); assert!(plugin.enabled); assert_eq!(plugin.build.len(), 1); assert_eq!(plugin.build[0].command, ["bun", "install"]); @@ -1466,6 +1481,174 @@ platforms = ["linux", "macos"] let _ = std::fs::remove_dir_all(root); } + #[cfg(windows)] + #[tokio::test] + async fn windows_plugin_pane_commands_resolve_from_plugin_root_with_cwd_override() { + use std::os::windows::ffi::OsStrExt; + + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("plugin-pane-paths")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.state.mode = crate::app::Mode::Terminal; + + let root = unique_temp_path("plugin-pane-paths"); + let child_cwd = root.join("child-cwd"); + std::fs::create_dir_all(&child_cwd).unwrap(); + let cleanup_root = root.canonicalize().unwrap(); + let tool = root.join("tool.exe"); + let long_where_relative = format!("bin/{}/where.exe", "x".repeat(220)); + let long_where = root.join(&long_where_relative); + std::fs::create_dir_all(long_where.parent().unwrap()).unwrap(); + let shell = std::env::var_os("ComSpec").expect("ComSpec"); + std::fs::copy(&shell, &tool).unwrap(); + let where_exe = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) + .join("System32/where.exe"); + std::fs::copy(where_exe, &long_where).unwrap(); + let where_probe = "herdr-long-path-probe.exe"; + std::fs::write(child_cwd.join(where_probe), b"").unwrap(); + assert!( + root.as_os_str().encode_wide().count() + < windows_sys::Win32::Foundation::MAX_PATH as usize + ); + assert!( + long_where.as_os_str().encode_wide().count() + >= windows_sys::Win32::Foundation::MAX_PATH as usize + ); + let script = "@echo off\r\n(echo %1&cd)>capture-%1.tmp\r\nmove /y capture-%1.tmp capture-%1.txt >nul\r\n"; + std::fs::write(root.join("slot.cmd"), script).unwrap(); + std::fs::write(child_cwd.join("slot.cmd"), script).unwrap(); + write_manifest_content( + &root, + &format!( + r#" +id = "example.pane-paths" +name = "Pane Paths" +version = "0.1.0" +min_herdr_version = "0.7.0" +platforms = ["windows"] + +[[panes]] +id = "explicit" +title = "Explicit" +command = ["./tool.exe", "/d", "/c", "slot.cmd", "explicit"] + +[[panes]] +id = "bare" +title = "Bare" +command = ["tool.exe", "/d", "/c", "slot.cmd", "bare"] + +[[panes]] +id = "path" +title = "Path" +command = ["cmd.exe", "/d", "/c", "slot.cmd", "path"] + +[[panes]] +id = "absolute" +title = "Absolute" +command = ['{}', "/d", "/c", "slot.cmd", "absolute"] + +[[panes]] +id = "long" +title = "Long executable" +command = ['./{}', "{}"] + +[[panes]] +id = "default" +title = "Default cwd" +command = ["cmd.exe", "/d", "/c", "slot.cmd", "default"] +"#, + tool.display(), + long_where_relative, + where_probe + ), + ); + link_manifest(&mut app, &root); + + for (entrypoint, cwd) in [ + ("explicit", Some(&child_cwd)), + ("bare", Some(&child_cwd)), + ("path", Some(&child_cwd)), + ("absolute", Some(&child_cwd)), + ("long", Some(&child_cwd)), + ("default", None), + ] { + let expected_cwd = cwd.unwrap_or(&root); + let open = app.handle_api_request(Request { + id: format!("pane-open-{entrypoint}"), + method: Method::PluginPaneOpen(PluginPaneOpenParams { + plugin_id: "example.pane-paths".into(), + entrypoint: entrypoint.into(), + placement: Some(PluginPanePlacement::Split), + width: None, + height: None, + workspace_id: None, + target_pane_id: None, + direction: None, + cwd: cwd.map(|path| path.display().to_string()), + focus: false, + env: std::collections::HashMap::new(), + }), + }); + let ResponseResult::PluginPaneOpened { plugin_pane } = response_result(&open) else { + panic!("failed to open {entrypoint} plugin pane: {open}"); + }; + if entrypoint == "long" { + let Some((ws_idx, pane_id)) = app.parse_pane_id(&plugin_pane.pane.pane_id) else { + panic!("opened pane id should parse"); + }; + let runtime = app + .state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, ws_idx, pane_id) + .expect("long executable pane runtime"); + let expected = child_cwd.join(where_probe).canonicalize().unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let text = runtime.recent_unwrapped_text(20); + if text.lines().any(|line| { + std::fs::canonicalize(line.trim()).is_ok_and(|path| path == expected) + }) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "long where.exe output missing from pane: {text:?}" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + continue; + } + let capture = read_capture_when_ready( + &expected_cwd.join(format!("capture-{entrypoint}.txt")), + || {}, + ); + let mut lines = capture.lines(); + assert_eq!(lines.next(), Some(entrypoint)); + let actual_cwd = std::fs::canonicalize(lines.next().expect("captured cwd")).unwrap(); + assert_eq!(actual_cwd, expected_cwd.canonicalize().unwrap()); + } + + for (_, runtime) in app.terminal_runtimes.drain() { + runtime.shutdown(); + } + // PTY actor shutdown is queued, so its Windows handles can outlive `shutdown()`. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match std::fs::remove_dir_all(&cleanup_root) { + Ok(()) => break, + Err(error) => { + assert!( + std::time::Instant::now() < deadline, + "failed to remove {} after runtime shutdown: {error}", + cleanup_root.display() + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + } + } + } + #[cfg(unix)] #[tokio::test] async fn plugin_pane_open_uses_plugin_root_title_env_and_target_context() { diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 402b695d..4b49b9f7 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -43,6 +43,15 @@ pub(crate) fn prepare_paste_text_for_pty(text: String) -> String { prepare_paste_text_for_pty_platform(text) } +pub(crate) fn plugin_runtime_path(path: &std::path::Path) -> std::path::PathBuf { + plugin_runtime_path_platform(path) +} + +#[cfg(not(windows))] +fn plugin_runtime_path_platform(path: &std::path::Path) -> std::path::PathBuf { + path.to_path_buf() +} + #[cfg(not(windows))] fn prepare_paste_text_for_pty_platform(text: String) -> String { text diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 10fbb6f0..1c5c4afb 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -30,7 +30,7 @@ use windows_sys::{ Win32::{ Foundation::{ CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE, - NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, + MAX_PATH, NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, Security::SECURITY_ATTRIBUTES, @@ -105,6 +105,44 @@ pub(crate) fn prepare_paste_text_for_pty_platform(text: String) -> String { text.replace("\r\n", "\n").replace('\n', "\r\n") } +pub(crate) fn plugin_runtime_path_platform(path: &std::path::Path) -> PathBuf { + use std::os::windows::ffi::OsStrExt; + + let Some(candidate) = standard_windows_path(path) else { + return path.to_path_buf(); + }; + // Rust can canonicalize a long standard path by adding its own verbatim prefix, but native + // process consumers still need the original prefix when the plugin root exceeds MAX_PATH. + if candidate.join("").as_os_str().encode_wide().count() >= MAX_PATH as usize { + return path.to_path_buf(); + } + match candidate.canonicalize() { + Ok(canonical) if canonical == path => candidate, + _ => path.to_path_buf(), + } +} + +fn standard_windows_path(path: &std::path::Path) -> Option<PathBuf> { + use std::path::{Component, Prefix}; + + let mut components = path.components(); + let Component::Prefix(prefix) = components.next()? else { + return None; + }; + let mut candidate = match prefix.kind() { + Prefix::VerbatimDisk(drive) => PathBuf::from(format!("{}:", char::from(drive))), + Prefix::VerbatimUNC(server, share) => { + let mut candidate = PathBuf::from(r"\\"); + candidate.push(server); + candidate.push(share); + candidate + } + _ => return None, + }; + candidate.push(components.as_path()); + Some(candidate) +} + /// Resolves against the current foreground layout because asynchronous console /// records do not retain the layout that was active when the key was pressed. pub(crate) fn resolve_base_printable_key(vk: u16, scan: u16) -> Option<char> { @@ -2570,6 +2608,73 @@ mod tests { }; #[test] + fn windows_standard_plugin_runtime_paths_drop_only_disk_and_unc_verbatim_prefixes() { + assert_eq!( + super::standard_windows_path(std::path::Path::new(r"\\?\C:\plugins\example")), + Some(std::path::PathBuf::from(r"C:\plugins\example")) + ); + assert_eq!( + super::standard_windows_path(std::path::Path::new( + r"\\?\UNC\server\share\plugins\example" + )), + Some(std::path::PathBuf::from(r"\\server\share\plugins\example")) + ); + assert_eq!( + super::standard_windows_path(std::path::Path::new( + r"\\?\Volume{01234567-89ab-cdef-0123-456789abcdef}\plugins" + )), + None + ); + } + + #[test] + fn windows_plugin_runtime_path_keeps_extended_path_when_normal_form_is_not_equivalent() { + let path = std::path::PathBuf::from(format!( + r"\\?\C:\herdr-missing-plugin-runtime-path-{}", + std::process::id() + )); + assert_eq!(super::plugin_runtime_path_platform(&path), path); + } + + #[test] + fn windows_plugin_runtime_path_keeps_verbatim_root_beyond_max_path() { + use std::os::windows::ffi::OsStrExt; + + let base = std::env::temp_dir().join(format!( + "herdr-plugin-runtime-path-limit-test-{}", + std::process::id() + )); + fs::create_dir_all(&base).expect("create test base"); + let extended_base = base.canonicalize().expect("canonicalize test base"); + let normal_base = super::standard_windows_path(&extended_base) + .expect("test base has a standard drive path"); + let normal_base_len = normal_base.as_os_str().encode_wide().count(); + let root_at_length = |length| { + let component_len = length - normal_base_len - 1; + let path = extended_base.join("é".repeat(component_len)); + fs::create_dir(&path).expect("create length-boundary test root"); + path.canonicalize() + .expect("canonicalize length-boundary test root") + }; + + let at_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 2); + let at_limit_normal = + super::standard_windows_path(&at_limit).expect("convert root at MAX_PATH boundary"); + assert_eq!( + super::plugin_runtime_path_platform(&at_limit), + at_limit_normal + ); + + let beyond_limit = root_at_length(windows_sys::Win32::Foundation::MAX_PATH as usize - 1); + assert_eq!( + super::plugin_runtime_path_platform(&beyond_limit), + beyond_limit + ); + + fs::remove_dir_all(base).expect("remove test directory"); + } + + #[test] fn paste_text_uses_windows_line_endings() { assert_eq!( super::prepare_paste_text_for_pty_platform("one\ntwo\r\nthree\rfour".to_owned()), diff --git a/src/plugin_command.rs b/src/plugin_command.rs index 6b639f0d..48f37c57 100644 --- a/src/plugin_command.rs +++ b/src/plugin_command.rs @@ -1,24 +1,28 @@ -use std::ffi::{OsStr, OsString}; -use std::path::Path; -#[cfg(windows)] -use std::path::PathBuf; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; use std::process::Command; pub(crate) fn command_for_argv_in_dir(program: &str, args: &[String], cwd: &Path) -> Command { let program = program_for_cwd(program, cwd); - let mut command = command_for_program(&program); + let mut command = command_for_program(program.as_os_str()); command.args(args).current_dir(cwd); command } -fn program_for_cwd(program: &str, cwd: &Path) -> OsString { +pub(crate) fn program_for_cwd(program: &str, cwd: &Path) -> PathBuf { let path = Path::new(program); let has_separator = program.contains('/') || (cfg!(windows) && program.contains('\\')); - if path.is_relative() && has_separator { + if path.is_relative() && (has_separator || (cfg!(windows) && cwd.join(path).is_file())) { let relative = path.strip_prefix(Path::new(".")).unwrap_or(path); - cwd.join(relative).into_os_string() + #[cfg(windows)] + if is_windows_batch_file_name(path.as_os_str()) { + return cwd.join(relative); + } + #[cfg(windows)] + let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); + cwd.join(relative) } else { - path.as_os_str().to_os_string() + path.to_path_buf() } } @@ -139,11 +143,8 @@ mod tests { fn resolves_explicit_relative_program_against_working_directory() { let cwd = Path::new("plugin-root"); - assert_eq!( - program_for_cwd("./bin/tool", cwd), - cwd.join("bin/tool").into_os_string() - ); - assert_eq!(program_for_cwd("tool", cwd), OsString::from("tool")); + assert_eq!(program_for_cwd("./bin/tool", cwd), cwd.join("bin/tool")); + assert_eq!(program_for_cwd("tool", cwd), PathBuf::from("tool")); } #[test] @@ -157,24 +158,29 @@ mod tests { #[cfg(windows)] #[test] fn windows_batch_command_captures_output() { - let path = std::env::temp_dir().join(format!( - "herdr-plugin-command-output-{}.cmd", + let root = std::env::temp_dir().join(format!( + "herdr plugin command output {}", std::process::id() )); + std::fs::create_dir_all(&root).expect("create batch fixture directory"); + let path = root.join("capture.cmd"); std::fs::write(&path, "@echo off\r\necho plugin-%1\r\n").expect("write batch fixture"); - let cwd = path.parent().expect("batch fixture parent"); - - let output = - command_for_argv_in_dir(&path.display().to_string(), &["ready".to_string()], cwd) + for program in [ + path.display().to_string(), + "./capture.cmd".to_string(), + "capture.cmd".to_string(), + ] { + let output = command_for_argv_in_dir(&program, &["ready".to_string()], &root) .output() .expect("run batch fixture"); - let _ = std::fs::remove_file(&path); - - assert!(output.status.success(), "{output:?}"); - assert_eq!( - String::from_utf8_lossy(&output.stdout).trim(), - "plugin-ready" - ); + assert!(output.status.success(), "{program}: {output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "plugin-ready", + "{program}" + ); + } + std::fs::remove_dir_all(root).expect("remove batch fixture directory"); } #[cfg(windows)] |
