use std::{ cmp::Ordering, collections::{HashMap, HashSet, VecDeque}, ffi::{c_void, OsStr}, mem::{size_of, MaybeUninit}, os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, path::PathBuf, ptr::{copy_nonoverlapping, null_mut}, sync::{ atomic::{AtomicU64, Ordering as AtomicOrdering}, Arc, LazyLock, Mutex, OnceLock, }, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; mod clipboard_image; pub(super) fn read_terminal_grid_size() -> std::io::Result<(u16, u16)> { crossterm::terminal::size() } pub(crate) fn replace_file( source: &std::path::Path, destination: &std::path::Path, ) -> std::io::Result<()> { use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Storage::FileSystem::{ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, }; let source = source .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect::>(); let destination = destination .as_os_str() .encode_wide() .chain(std::iter::once(0)) .collect::>(); let moved = unsafe { MoveFileExW( source.as_ptr(), destination.as_ptr(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, ) }; if moved == 0 { Err(std::io::Error::last_os_error()) } else { Ok(()) } } pub(crate) fn set_default_plugin_pane_pwd( _env: &mut Vec<(String, String)>, _cwd: &std::path::Path, ) { } use windows_sys::{ Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, Win32::{ Foundation::{ CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE, NTSTATUS, STATUS_SUCCESS, UNICODE_STRING, }, Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN}, Security::SECURITY_ATTRIBUTES, Storage::FileSystem::CreateDirectoryW, System::{ Console::GetConsoleWindow, DataExchange::{ CloseClipboard, EmptyClipboard, GetClipboardData, OpenClipboard, RegisterClipboardFormatW, SetClipboardData, }, Diagnostics::{ Debug::ReadProcessMemory, ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, Thread32First, Thread32Next, PROCESSENTRY32W, TH32CS_SNAPPROCESS, TH32CS_SNAPTHREAD, THREADENTRY32, }, }, JobObjects::{ AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }, Memory::{ GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE, MEMORY_BASIC_INFORMATION, }, Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT}, Threading::{ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenThread, QueryFullProcessImageNameW, ResumeThread, TerminateProcess, CREATE_NO_WINDOW, CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, THREAD_SUSPEND_RESUME, }, }, UI::{ Input::{ Ime::ImmGetDefaultIMEWnd, KeyboardAndMouse::{ GetKeyboardLayout, SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, }, }, Shell::{ CommandLineToArgvW, ShellExecuteW, Shell_NotifyIconW, NIF_ICON, NIF_INFO, NIF_TIP, NIIF_INFO, NIIF_NOSOUND, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, }, WindowsAndMessaging::{ CreateWindowExW, DestroyWindow, GetForegroundWindow, GetWindowThreadProcessId, LoadIconW, SendMessageTimeoutW, IDI_APPLICATION, SMTO_ABORTIFHUNG, WM_IME_CONTROL, }, }, }, }; use super::{ClipboardImage, ForegroundJob, Signal}; const STILL_ACTIVE: u32 = 259; const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250); const FOREGROUND_SELECTION_RECHECK: Duration = Duration::from_secs(5); const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024; const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60); const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID"; pub(crate) fn terminal_title_for_presentation(title: &str) -> &str { title.strip_prefix("Administrator: ").unwrap_or(title) } const MAX_PROCESS_ENVIRONMENT_BYTES: usize = 256 * 1024; const PROCESS_ENVIRONMENT_READ_CHUNK_BYTES: usize = 16 * 1024; const PROCESS_RUNTIME_MARKER_CACHE_CAPACITY: usize = 1_024; const PROCESS_RUNTIME_MARKER_CACHE_RETENTION: Duration = Duration::from_secs(60); const PROCESS_RUNTIME_MARKER_NEGATIVE_TTL: Duration = Duration::from_secs(1); static NEXT_PANE_RUNTIME_MARKER: AtomicU64 = AtomicU64::new(1); static PROCESS_RUNTIME_MARKER_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static GIT_BASH_PROCESS_CACHE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); pub(crate) fn remote_ssh_config_paths() -> super::RemoteSshConfigPaths { super::RemoteSshConfigPaths { user_config: std::env::var_os("USERPROFILE") .map(PathBuf::from) .map(|home| home.join(".ssh").join("config")), system_config: std::env::var_os("PROGRAMDATA") .map(PathBuf::from) .map(|dir| dir.join("ssh").join("ssh_config")), multiplexing: false, } } pub(crate) fn create_remote_ssh_config_dir(_control_socket_name: &str) -> std::io::Result { let base = remote_private_temp_base(); std::fs::create_dir_all(&base)?; for attempt in 0..100 { let dir = base.join(format!("ssh-{}-{attempt}", std::process::id())); match create_remote_private_dir(&dir) { Ok(()) => return Ok(dir), Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(err) => return Err(err), } } Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "failed to create private herdr ssh config directory", )) } pub(crate) fn create_remote_ssh_config_file( path: &std::path::Path, ) -> std::io::Result { std::fs::OpenOptions::new() .write(true) .create_new(true) .open(path) } pub(crate) fn create_remote_private_dir(path: &std::path::Path) -> std::io::Result<()> { use interprocess::os::windows::security_descriptor::{ AsSecurityDescriptorExt as _, SecurityDescriptor, }; use widestring::U16CString; let sddl = U16CString::from_str("D:P(A;OICI;GA;;;SY)(A;OICI;GA;;;OW)") .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?; let security_descriptor = SecurityDescriptor::deserialize(&sddl)?; let mut security_attributes = SECURITY_ATTRIBUTES { nLength: u32::try_from(size_of::()).unwrap_or(u32::MAX), lpSecurityDescriptor: null_mut(), bInheritHandle: 0, }; security_descriptor.write_to_security_attributes(&mut security_attributes); let path = extended_length_path(path)?; if unsafe { CreateDirectoryW(path.as_ptr(), &security_attributes) } != 0 { Ok(()) } else { Err(std::io::Error::last_os_error()) } } fn extended_length_path(path: &std::path::Path) -> std::io::Result> { use std::os::windows::ffi::OsStrExt as _; let path = std::path::absolute(path)?; let wide = path.as_os_str().encode_wide().collect::>(); let mut extended = if wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'?' as u16, b'\\' as u16]) || wide.starts_with(&[b'\\' as u16, b'\\' as u16, b'.' as u16, b'\\' as u16]) { wide } else if wide.starts_with(&[b'\\' as u16, b'\\' as u16]) { "\\\\?\\UNC\\" .encode_utf16() .chain(wide.into_iter().skip(2)) .collect() } else { "\\\\?\\".encode_utf16().chain(wide).collect() }; extended.push(0); Ok(extended) } pub(crate) fn remote_private_temp_base() -> PathBuf { crate::config::state_dir().join("remote") } pub(crate) fn remote_bridge_endpoint_path(_readable_name: &str, short_name: &str) -> PathBuf { remote_private_temp_base().join(short_name) } pub(crate) fn remote_reattach_program(program: &str) -> String { let path = std::env::current_exe() .ok() .filter(|path| path.is_absolute()) .unwrap_or_else(|| PathBuf::from(program)); format!( "& {}", remote_reattach_argument(&path.display().to_string()) ) } pub(crate) fn remote_reattach_argument(value: &str) -> String { format!("'{}'", value.replace('\'', "''")) } /// Encode native or targeted semantic Win32 input for a compatible ConPTY destination. pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option> { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; let (virtual_key_code, virtual_scan_code, unicode, control_key_state) = if let Some(record) = key.windows_record() { ( record.virtual_key_code, record.virtual_scan_code, record.unicode, record.control_key_state, ) } else if key.code == KeyCode::Esc && key.modifiers.is_empty() && key.kind == KeyEventKind::Press && key.vt_bytes().is_none() { return Some(b"\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_".to_vec()); } else if key.code == KeyCode::Enter && key.modifiers == KeyModifiers::SHIFT { (13, 28, 13, 16) } else { return None; }; let key_down = key.kind != KeyEventKind::Release; let repeat_count = if key_down { key.repeat_count.max(1) } else { 1 }; Some( format!( "\x1b[{virtual_key_code};{virtual_scan_code};{unicode};{};{control_key_state};{repeat_count}_", u8::from(key_down), ) .into_bytes(), ) } #[derive(Debug)] struct CachedProcessSnapshot { built_at: Instant, snapshot: Arc, } #[derive(Debug)] struct ProcessSnapshotCache { cached: Option, } #[derive(Debug, Clone, PartialEq, Eq)] struct ProcessSignature { pid: u32, parent_pid: u32, name: String, } impl ProcessSignature { fn from_entry(entry: &WindowsProcessEntry) -> Self { Self { pid: entry.pid, parent_pid: entry.parent_pid, name: entry.name.clone(), } } fn matches(&self, entry: Option<&WindowsProcessEntry>) -> bool { entry.is_some_and(|entry| { self.pid == entry.pid && self.parent_pid == entry.parent_pid && self.name == entry.name }) } } #[derive(Debug)] enum ProcessIdentity { Handle(OwnedHandle), #[cfg(test)] Stub { running: bool, creation_time: Option, }, } impl ProcessIdentity { fn open(pid: u32) -> Option { let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return None; } let handle = unsafe { OwnedHandle::from_raw_handle(handle.cast()) }; Some(Self::Handle(handle)) } fn running(&self) -> bool { match self { Self::Handle(handle) => { let mut exit_code = 0; let read = unsafe { GetExitCodeProcess(handle.as_raw_handle().cast(), &mut exit_code) != 0 }; read && exit_code == STILL_ACTIVE } #[cfg(test)] Self::Stub { running, .. } => *running, } } fn creation_time(&self) -> Option { match self { Self::Handle(handle) => process_creation_time(handle.as_raw_handle().cast()), #[cfg(test)] Self::Stub { creation_time, .. } => *creation_time, } } } #[derive(Debug)] struct CachedForegroundSelection { shell: ProcessSignature, selected: ProcessSignature, descendants: Vec, descendant_identities: Vec, shell_identity: ProcessIdentity, selected_identity: ProcessIdentity, job: ForegroundJob, verified_at: Instant, last_used: Instant, } #[derive(Debug, Default)] struct ForegroundSelectionCache { entries: HashMap, } #[derive(Debug)] struct CachedProcessRuntimeMarker { creation_time: u64, marker: Option, cached_at: Instant, last_used: Instant, } #[derive(Debug)] struct CachedGitBashProcess { creation_time: u64, is_git_bash: bool, last_used: Instant, } static FOREGROUND_PROCESS_SNAPSHOT_CACHE: Mutex = Mutex::new(ProcessSnapshotCache { cached: None }); static FOREGROUND_SELECTION_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(ForegroundSelectionCache::default())); pub(crate) fn should_draw_host_cursor_by_default() -> bool { true } pub(crate) fn should_query_host_terminal_palette() -> bool { false } /// The machine's node name, as shown by tmux's `#h`. pub(crate) fn hostname() -> Option { std::env::var("COMPUTERNAME") .ok() .filter(|name| !name.is_empty()) } pub(crate) fn local_datetime() -> Option { let mut timestamp: libc::time_t = 0; if unsafe { libc::time(&mut timestamp) } == -1 { return None; } let mut local: libc::tm = unsafe { std::mem::zeroed() }; if unsafe { libc::localtime_s(&mut local, ×tamp) } != 0 { return None; } let month = time::Month::try_from(u8::try_from(local.tm_mon + 1).ok()?).ok()?; let date = time::Date::from_calendar_date( local.tm_year + 1900, month, u8::try_from(local.tm_mday).ok()?, ) .ok()?; let time = time::Time::from_hms( u8::try_from(local.tm_hour).ok()?, u8::try_from(local.tm_min).ok()?, u8::try_from(local.tm_sec).ok()?, ) .ok()?; Some(time::PrimitiveDateTime::new(date, time)) } #[derive(Debug, Clone, PartialEq, Eq)] struct WindowsProcessCommand { creation_time: Option, argv0: Option, argv: Option>, cmdline: Option, } #[derive(Debug, Clone)] struct WindowsProcessEntry { pid: u32, parent_pid: u32, name: String, command: OnceLock, } impl WindowsProcessEntry { fn command(&self) -> &WindowsProcessCommand { self.command .get_or_init(|| read_process_command(self.pid, &self.name)) } } #[derive(Debug)] struct ProcessSnapshot { entries: Vec, entry_by_pid: HashMap, children_by_parent: HashMap>, agent_indices: OnceLock>, } impl ProcessSnapshot { fn new(entries: Vec) -> Self { let mut entry_by_pid = HashMap::with_capacity(entries.len()); let mut children_by_parent = HashMap::>::new(); for (index, entry) in entries.iter().enumerate() { entry_by_pid.insert(entry.pid, index); children_by_parent .entry(entry.parent_pid) .or_default() .push(index); } Self { entries, entry_by_pid, children_by_parent, agent_indices: OnceLock::new(), } } fn entry(&self, pid: u32) -> Option<&WindowsProcessEntry> { self.entry_by_pid .get(&pid) .map(|&index| &self.entries[index]) } fn descendant_signatures(&self, root_pid: u32) -> Vec { let mut signatures = descendant_entries(root_pid, self) .into_iter() .map(ProcessSignature::from_entry) .collect::>(); signatures.sort_unstable_by_key(|entry| entry.pid); signatures } fn agent_indices(&self) -> &[usize] { self.agent_indices.get_or_init(|| { self.entries .iter() .enumerate() .filter_map(|(index, entry)| process_entry_identifies_agent(entry).then_some(index)) .collect() }) } } pub fn raise_server_nofile_limit() {} pub(crate) fn apply_pane_runtime_marker_platform(command: &mut portable_pty::CommandBuilder) { if command_uses_git_bash(command) { command.env(PANE_RUNTIME_MARKER_ENV_VAR, next_pane_runtime_marker()); } } fn next_pane_runtime_marker() -> String { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or(0); let counter = NEXT_PANE_RUNTIME_MARKER.fetch_add(1, AtomicOrdering::Relaxed); format!("{:x}-{timestamp:x}-{counter:x}", std::process::id()) } fn raw_command_shell(comspec: Option) -> std::ffi::OsString { comspec .filter(|value| !value.is_empty()) .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) } pub(crate) fn interactive_shell_command(argv: &[String], shell_name: &str) -> Option { let shell_name = shell_name.to_ascii_lowercase(); let powershell = shell_name.contains("powershell") || shell_name.contains("pwsh"); let script = powershell_agent_script(argv)?; if powershell { Some(script) } else { Some(cmd_encoded_powershell_command(&script)) } } fn powershell_agent_script(argv: &[String]) -> Option { let (program, args) = argv.split_first()?; if args.is_empty() { return Some(format!("& {}", super::quote_powershell_arg(program))); } let powershell_args = args .iter() .map(|arg| super::quote_powershell_arg(arg)) .collect::>() .join(" "); let command_line = args .iter() .map(|arg| quote_windows_command_line_arg(arg)) .collect::>() .join(" "); Some(format!( "if((Get-Command {} -ErrorAction SilentlyContinue).CommandType -eq 'ExternalScript'){{& {} {}}}else{{Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -Wait}}", super::quote_powershell_arg(program), super::quote_powershell_arg(program), powershell_args, super::quote_powershell_arg(program), super::quote_powershell_arg(&command_line), )) } fn quote_windows_command_line_arg(value: &str) -> String { if !value.is_empty() && !value .chars() .any(|ch| matches!(ch, ' ' | '\t' | '\n' | '\x0b' | '"')) { return value.to_string(); } let mut quoted = String::from("\""); let mut backslashes = 0; for ch in value.chars() { if ch == '\\' { backslashes += 1; continue; } if ch == '"' { quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); } else { quoted.push_str(&"\\".repeat(backslashes)); } backslashes = 0; quoted.push(ch); } quoted.push_str(&"\\".repeat(backslashes * 2)); quoted.push('"'); quoted } fn cmd_encoded_powershell_command(script: &str) -> String { use base64::Engine as _; let utf16 = script .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); let encoded = base64::engine::general_purpose::STANDARD.encode(utf16); format!("powershell.exe -NoLogo -NoProfile -EncodedCommand {encoded}") } pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::process::Command { detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec")) } pub(crate) fn status_commands_supported() -> bool { true } pub(crate) fn configure_status_command(process: &mut std::process::Command) { use std::os::windows::process::CommandExt; // The process must not run before it is assigned to the kill-on-close job. process.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); } pub(crate) struct StatusCommandGuard { job: usize, } impl StatusCommandGuard { pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result { let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; if job.is_null() { return Err(std::io::Error::last_os_error()); } let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; let limits_size = match u32::try_from(size_of::()) { Ok(size) => size, Err(_) => { unsafe { CloseHandle(job); } return Err(std::io::Error::other("job limits size exceeds u32")); } }; if unsafe { SetInformationJobObject( job, JobObjectExtendedLimitInformation, std::ptr::from_ref(&limits).cast(), limits_size, ) } == 0 { let error = std::io::Error::last_os_error(); unsafe { CloseHandle(job); } return Err(error); } let Some(process) = child.raw_handle() else { unsafe { CloseHandle(job); } return Err(std::io::Error::other( "status command has no process handle", )); }; if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 { let error = std::io::Error::last_os_error(); unsafe { CloseHandle(job); } return Err(error); } if let Err(error) = resume_suspended_process(child.id()) { unsafe { CloseHandle(job); } return Err(error); } Ok(Self { job: job as usize }) } } fn resume_suspended_process(process_id: Option) -> std::io::Result<()> { let process_id = process_id.ok_or_else(|| std::io::Error::other("status command has no process id"))?; let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; if snapshot == INVALID_HANDLE_VALUE { return Err(std::io::Error::last_os_error()); } let result = (|| { let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() }; entry.dwSize = u32::try_from(size_of::()) .map_err(|_| std::io::Error::other("thread entry size exceeds u32"))?; if unsafe { Thread32First(snapshot, &mut entry) } == 0 { return Err(std::io::Error::last_os_error()); } loop { if entry.th32OwnerProcessID == process_id { let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) }; if thread.is_null() { return Err(std::io::Error::last_os_error()); } let resume_result = unsafe { ResumeThread(thread) }; let resume_error = (resume_result == u32::MAX).then(std::io::Error::last_os_error); unsafe { CloseHandle(thread); } if let Some(error) = resume_error { return Err(error); } return Ok(()); } if unsafe { Thread32Next(snapshot, &mut entry) } == 0 { return Err(std::io::Error::other( "status command primary thread was not found", )); } } })(); unsafe { CloseHandle(snapshot); } result } impl StatusCommandGuard { pub(crate) fn terminate(&mut self) { if self.job != 0 { // KILL_ON_JOB_CLOSE terminates the shell and every descendant still in // the job, including on task cancellation and config reload. unsafe { CloseHandle(self.job as HANDLE); } self.job = 0; } } } impl Drop for StatusCommandGuard { fn drop(&mut self) { self.terminate(); } } fn detached_custom_command_process_with_comspec( command: &str, comspec: Option, ) -> std::process::Command { use std::os::windows::process::CommandExt; let mut process = std::process::Command::new(raw_command_shell(comspec)); process.arg("/d").arg("/c").raw_arg(command); process } pub(crate) fn pane_custom_command_pty_builder_platform( command: &str, ) -> portable_pty::CommandBuilder { pane_custom_command_pty_builder_with_comspec(command, std::env::var_os("ComSpec")) } fn pane_custom_command_pty_builder_with_comspec( command: &str, comspec: Option, ) -> portable_pty::CommandBuilder { let mut builder = portable_pty::CommandBuilder::new(raw_command_shell(comspec)); builder.arg("/d"); builder.arg("/c"); builder.raw_arg(command); builder } pub(crate) fn scrollback_editor_argv(path: &std::path::Path) -> std::io::Result> { let editor = std::env::var("VISUAL") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| { std::env::var("EDITOR") .ok() .filter(|value| !value.trim().is_empty()) }); scrollback_editor_argv_with_env(path, editor.as_deref()) } fn scrollback_editor_argv_with_env( path: &std::path::Path, editor: Option<&str>, ) -> std::io::Result> { let mut argv = match editor.filter(|value| !value.trim().is_empty()) { Some(editor) => command_line_to_argv(editor).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("failed to parse editor command {editor:?}"), ) })?, None => vec!["notepad.exe".to_string()], }; if argv.is_empty() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "editor command must not be empty", )); } argv.push(path.display().to_string()); Ok(argv) } pub(crate) fn configure_background_command_platform(command: &mut std::process::Command) { use std::os::windows::process::CommandExt; command.creation_flags(CREATE_NO_WINDOW); } pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result { if current_job_kills_processes_on_close()? { launch_server_daemon_with_wmi(command) } else { command.spawn().map(|child| child.id()) } } fn launch_server_daemon_with_wmi(command: &std::process::Command) -> std::io::Result { // WMI resolves the class from this Rust type name, including CIM casing. #[allow(non_camel_case_types)] #[derive(serde::Deserialize)] struct Win32_Process; // WMI serializes this embedded object using the matching CIM class name. #[allow(non_camel_case_types)] #[derive(serde::Serialize)] struct Win32_ProcessStartup { #[serde(rename = "CreateFlags")] create_flags: u32, #[serde(rename = "EnvironmentVariables")] environment_variables: Vec, } #[derive(serde::Serialize)] struct CreateInput { #[serde(rename = "CommandLine")] command_line: String, #[serde(rename = "CurrentDirectory")] current_directory: String, #[serde(rename = "ProcessStartupInformation")] process_startup_information: Win32_ProcessStartup, } #[derive(serde::Deserialize)] struct CreateOutput { #[serde(rename = "ProcessId")] process_id: Option, #[serde(rename = "ReturnValue")] return_value: u32, } let current_directory = command .get_current_dir() .map(std::path::Path::to_path_buf) .map(Ok) .unwrap_or_else(std::env::current_dir)?; let input = CreateInput { command_line: windows_command_line(command)?, current_directory: unicode_windows_value( ¤t_directory.into_os_string(), "working directory", )?, process_startup_information: Win32_ProcessStartup { create_flags: DETACHED_PROCESS, environment_variables: effective_command_environment(command)?, }, }; let connection = wmi::WMIConnection::new() .map_err(|err| std::io::Error::other(format!("failed to connect to WMI: {err}")))?; let output: CreateOutput = connection .exec_class_method::("Create", &input) .map_err(|err| std::io::Error::other(format!("WMI Win32_Process.Create failed: {err}")))?; if output.return_value != 0 { return Err(std::io::Error::other(format!( "WMI Win32_Process.Create returned error {}", output.return_value ))); } output.process_id.ok_or_else(|| { std::io::Error::other("WMI Win32_Process.Create succeeded without a process id") }) } fn windows_command_line(command: &std::process::Command) -> std::io::Result { std::iter::once(command.get_program()) .chain(command.get_args()) .map(|value| { unicode_windows_value(value, "server command argument") .map(|value| quote_windows_command_line_arg(&value)) }) .collect::>>() .map(|parts| parts.join(" ")) } fn effective_command_environment(command: &std::process::Command) -> std::io::Result> { let mut environment = std::env::vars_os() .map(|(key, value)| { Ok(( unicode_windows_value(&key, "inherited environment variable name")?, unicode_windows_value(&value, "inherited environment variable value")?, )) }) .collect::>>()?; for (key, value) in command.get_envs() { let key = unicode_windows_value(key, "environment variable name")?; environment.retain(|(inherited, _)| windows_environment_key_cmp(inherited, &key).is_ne()); if let Some(value) = value { environment.push(( key, unicode_windows_value(value, "environment variable value")?, )); } } environment.sort_unstable_by(|(left, _), (right, _)| windows_environment_key_cmp(left, right)); Ok(environment .into_iter() .map(|(key, value)| format!("{key}={value}")) .collect()) } fn windows_environment_key_cmp(left: &str, right: &str) -> Ordering { let left_wide: Vec = left.encode_utf16().collect(); let right_wide: Vec = right.encode_utf16().collect(); // SAFETY: both pointers remain valid for the call and lengths count UTF-16 units. match unsafe { CompareStringOrdinal( left_wide.as_ptr(), left_wide.len() as i32, right_wide.as_ptr(), right_wide.len() as i32, 1, ) } { CSTR_LESS_THAN => Ordering::Less, CSTR_EQUAL => Ordering::Equal, CSTR_GREATER_THAN => Ordering::Greater, _ => left.cmp(right), } } fn unicode_windows_value(value: &OsStr, label: &str) -> std::io::Result { value.to_str().map(str::to_owned).ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidData, format!("{label} is not valid Unicode"), ) }) } fn current_process_is_in_job() -> std::io::Result { let mut in_job = 0; // SAFETY: `in_job` is a valid writable BOOL for the duration of the call. if unsafe { IsProcessInJob(GetCurrentProcess(), null_mut(), &mut in_job) } == 0 { return Err(std::io::Error::last_os_error()); } Ok(in_job != 0) } fn current_job_kills_processes_on_close() -> std::io::Result { if !current_process_is_in_job()? { return Ok(false); } let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); // SAFETY: `limits` is writable and its exact buffer size is supplied. if unsafe { QueryInformationJobObject( null_mut(), JobObjectExtendedLimitInformation, &mut limits as *mut _ as *mut c_void, size_of::() as u32, null_mut(), ) } == 0 { return Err(std::io::Error::last_os_error()); } Ok(limits.BasicLimitInformation.LimitFlags & JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE != 0) } pub fn detach_server_daemon_command(command: &mut std::process::Command) { use std::os::windows::process::CommandExt; command.creation_flags(DETACHED_PROCESS); } pub fn current_process_is_detached_server_daemon() -> bool { if !unsafe { GetConsoleWindow() }.is_null() { return false; } matches!(current_process_is_in_job(), Ok(false)) } pub fn foreground_job(child_pid: u32) -> Option { select_pane_foreground_job_cached(child_pid) } pub(crate) fn available_pane_shell(child_pid: u32) -> Option { let snapshot = ProcessSnapshot::new(snapshot_processes()); available_pane_shell_from_snapshot(child_pid, &snapshot) } fn available_pane_shell_from_snapshot( child_pid: u32, snapshot: &ProcessSnapshot, ) -> Option { let shell = snapshot.entry(child_pid)?; if !super::is_pane_shell_process_name(&shell.name) { return None; } descendant_entries(child_pid, snapshot) .is_empty() .then(|| shell.name.clone()) } pub fn foreground_group_leader_job(process_group_id: u32) -> Option { let snapshot = cached_foreground_processes(); let entry = snapshot.entry(process_group_id)?; Some(ForegroundJob { process_group_id, processes: vec![foreground_process_from_entry(entry)], }) } pub fn foreground_process_group_id(child_pid: u32) -> Option { select_pane_foreground_job_cached(child_pid).map(|job| job.process_group_id) } pub fn process_cwd(pid: u32) -> Option { let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?; let process_parameters = read_process_parameters(process.0)?; read_unicode_string(process.0, process_parameters.current_directory.dos_path) .map(PathBuf::from) .filter(|path| path.is_absolute()) } fn select_pane_foreground_job_cached(shell_pid: u32) -> Option { let snapshot = cached_foreground_processes(); let (job, retry_with_fresh_snapshot) = select_pane_foreground_job_from_snapshot(shell_pid, &snapshot)?; if !retry_with_fresh_snapshot { return Some(job); } let snapshot = fresh_foreground_processes(); select_pane_foreground_job_from_snapshot(shell_pid, &snapshot).map(|(job, _)| job) } fn select_pane_foreground_job_from_snapshot( shell_pid: u32, snapshot: &ProcessSnapshot, ) -> Option<(ForegroundJob, bool)> { if let Some(job) = FOREGROUND_SELECTION_CACHE .lock() .unwrap_or_else(|err| err.into_inner()) .get(shell_pid, snapshot) { return Some((job, false)); } let job = select_pane_foreground_job_from_snapshot_uncached(shell_pid, snapshot)?; let cached = prepare_cached_foreground_selection(shell_pid, snapshot, &job); let retry_with_fresh_snapshot = job.process_group_id != shell_pid && cached.is_none(); FOREGROUND_SELECTION_CACHE .lock() .unwrap_or_else(|err| err.into_inner()) .remember(shell_pid, cached); Some((job, retry_with_fresh_snapshot)) } fn select_pane_foreground_job_from_snapshot_uncached( shell_pid: u32, snapshot: &ProcessSnapshot, ) -> Option { select_pane_foreground_job_from_snapshot_with_runtime_inspection( shell_pid, snapshot, |shell| process_is_git_bash(shell.pid), |entry| process_runtime_marker(entry.pid), ) } fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( shell_pid: u32, snapshot: &ProcessSnapshot, shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool, mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option, ) -> Option { let entries = &snapshot.entries; let shell = snapshot.entry(shell_pid)?; let descendants = descendant_entries(shell_pid, snapshot); let mut candidates = Vec::new(); for entry in std::iter::once(shell).chain(descendants) { if process_entry_identifies_agent(entry) { candidates.push(entry); } } if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, snapshot) { return Some(foreground_job_from_entry(selected)); } if !candidates.is_empty() || !shell_is_git_bash(shell) { return Some(foreground_job_from_entry(shell)); } let escaped_agent_indices = snapshot.agent_indices(); if escaped_agent_indices.is_empty() { return Some(foreground_job_from_entry(shell)); } let Some(shell_runtime_marker) = runtime_marker(shell).filter(|marker| !marker.is_empty()) else { return Some(foreground_job_from_entry(shell)); }; let matching_candidates: Vec<_> = escaped_agent_indices .iter() .map(|&index| &entries[index]) .filter(|entry| runtime_marker(entry).as_deref() == Some(shell_runtime_marker.as_str())) .collect(); let selected = select_topmost_agent_chain_candidate(&matching_candidates, snapshot).unwrap_or(shell); Some(foreground_job_from_entry(selected)) } #[cfg(test)] fn select_pane_foreground_job( shell_pid: u32, entries: &[WindowsProcessEntry], ) -> Option { select_pane_foreground_job_from_snapshot_uncached( shell_pid, &ProcessSnapshot::new(entries.to_vec()), ) } fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool { crate::detect::identify_agent(&entry.name).is_some() || crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() } fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob { ForegroundJob { process_group_id: entry.pid, processes: vec![foreground_process_from_entry(entry)], } } fn select_topmost_agent_chain_candidate<'a>( candidates: &[&'a WindowsProcessEntry], snapshot: &ProcessSnapshot, ) -> Option<&'a WindowsProcessEntry> { if candidates.is_empty() { return None; } candidates.iter().copied().find(|entry| { candidates.iter().all(|other| { entry.pid == other.pid || process_is_ancestor(entry.pid, other.pid, snapshot) }) }) } fn process_is_ancestor(ancestor_pid: u32, descendant_pid: u32, snapshot: &ProcessSnapshot) -> bool { let mut current = descendant_pid; let mut visited = HashSet::new(); while visited.insert(current) { let Some(parent) = snapshot.entry(current).map(|entry| entry.parent_pid) else { return false; }; if parent == ancestor_pid { return true; } if parent == 0 { return false; } current = parent; } false } fn descendant_entries(root_pid: u32, snapshot: &ProcessSnapshot) -> Vec<&WindowsProcessEntry> { let mut output = Vec::new(); let mut queue = VecDeque::new(); let mut visited = HashSet::new(); visited.insert(root_pid); if let Some(root_children) = snapshot.children_by_parent.get(&root_pid) { for &index in root_children { let entry = &snapshot.entries[index]; if visited.insert(entry.pid) { queue.push_back(entry); } } } while let Some(entry) = queue.pop_front() { output.push(entry); if let Some(next) = snapshot.children_by_parent.get(&entry.pid) { for &index in next { let child = &snapshot.entries[index]; if visited.insert(child.pid) { queue.push_back(child); } } } } output } fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess { let command = entry.command(); super::ForegroundProcess { pid: entry.pid, name: entry.name.clone(), argv0: command.argv0.clone(), argv: command.argv.clone(), cmdline: command.cmdline.clone(), } } fn snapshot_processes() -> Vec { let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; if snapshot == INVALID_HANDLE_VALUE { return Vec::new(); } let _snapshot = ProcessHandle(snapshot); let mut entry = PROCESSENTRY32W { dwSize: size_of::() as u32, ..Default::default() }; let mut output = Vec::new(); let mut ok = unsafe { Process32FirstW(snapshot, &mut entry) } != 0; while ok { let pid = entry.th32ProcessID; let name = nul_terminated_utf16_to_string(&entry.szExeFile); output.push(WindowsProcessEntry { pid, parent_pid: entry.th32ParentProcessID, name, command: OnceLock::new(), }); ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0; } output } fn cached_foreground_processes() -> Arc { let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); cache.snapshot(FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL, snapshot_processes) } fn fresh_foreground_processes() -> Arc { let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); cache.snapshot(Duration::ZERO, snapshot_processes) } fn prepare_cached_foreground_selection( shell_pid: u32, snapshot: &ProcessSnapshot, job: &ForegroundJob, ) -> Option { if job.process_group_id == shell_pid { return None; } let shell_identity = ProcessIdentity::open(shell_pid)?; let selected_identity = ProcessIdentity::open(job.process_group_id)?; let descendants = snapshot.descendant_signatures(shell_pid); let descendant_identities = descendants .iter() .map(|entry| ProcessIdentity::open(entry.pid)) .collect::>>()?; CachedForegroundSelection::from_snapshot_with_identities( shell_pid, snapshot, job, descendants, descendant_identities, shell_identity, selected_identity, ) } impl CachedForegroundSelection { fn from_snapshot_with_identities( shell_pid: u32, snapshot: &ProcessSnapshot, job: &ForegroundJob, descendants: Vec, descendant_identities: Vec, shell_identity: ProcessIdentity, selected_identity: ProcessIdentity, ) -> Option { if job.process_group_id == shell_pid { return None; } let shell_entry = snapshot.entry(shell_pid)?; if shell_identity.creation_time() != shell_entry.command().creation_time { return None; } let shell = ProcessSignature::from_entry(shell_entry); let selected_entry = snapshot.entry(job.process_group_id)?; if selected_identity.creation_time() != selected_entry.command().creation_time { return None; } let selected = ProcessSignature::from_entry(selected_entry); let descendants_match_identities = descendants.len() == descendant_identities.len() && descendants .iter() .zip(&descendant_identities) .all(|(signature, identity)| { snapshot.entry(signature.pid).is_some_and(|entry| { identity.creation_time() == entry.command().creation_time }) }); if !descendants_match_identities || !shell_identity.running() || !selected_identity.running() || !descendant_identities.iter().all(ProcessIdentity::running) { return None; } let now = Instant::now(); Some(Self { shell, selected, descendants, descendant_identities, shell_identity, selected_identity, job: job.clone(), verified_at: now, last_used: now, }) } } impl ForegroundSelectionCache { fn get(&mut self, shell_pid: u32, snapshot: &ProcessSnapshot) -> Option { if let Some(cached) = self.entries.get_mut(&shell_pid) { let current_descendants = descendant_entries(shell_pid, snapshot); let topology_matches = current_descendants.len() == cached.descendants.len() && cached .descendants .iter() .all(|entry| entry.matches(snapshot.entry(entry.pid))); let valid = cached.verified_at.elapsed() < FOREGROUND_SELECTION_RECHECK && cached.shell_identity.running() && cached.selected_identity.running() && cached .descendant_identities .iter() .all(ProcessIdentity::running) && cached.shell.matches(snapshot.entry(shell_pid)) && cached .selected .matches(snapshot.entry(cached.job.process_group_id)) && topology_matches; if valid { cached.last_used = Instant::now(); return Some(cached.job.clone()); } } self.entries.remove(&shell_pid); None } fn remember(&mut self, shell_pid: u32, cached: Option) { let Some(cached) = cached else { self.entries.remove(&shell_pid); return; }; self.entries .retain(|_, cached| cached.last_used.elapsed() < FOREGROUND_SELECTION_CACHE_RETENTION); if self.entries.len() >= FOREGROUND_SELECTION_CACHE_CAPACITY { self.entries.clear(); } self.entries.insert(shell_pid, cached); } #[cfg(test)] fn remember_for_test( &mut self, shell_pid: u32, snapshot: &ProcessSnapshot, job: &ForegroundJob, ) { let descendants = snapshot.descendant_signatures(shell_pid); let descendant_identities = descendants .iter() .map(|_| ProcessIdentity::Stub { running: true, creation_time: None, }) .collect(); let cached = CachedForegroundSelection::from_snapshot_with_identities( shell_pid, snapshot, job, descendants, descendant_identities, ProcessIdentity::Stub { running: true, creation_time: None, }, ProcessIdentity::Stub { running: true, creation_time: None, }, ); self.remember(shell_pid, cached); } } impl ProcessSnapshotCache { fn snapshot( &mut self, max_age: Duration, build: impl FnOnce() -> Vec, ) -> Arc { if let Some(cached) = &self.cached { if cached.built_at.elapsed() < max_age { return Arc::clone(&cached.snapshot); } } let snapshot = Arc::new(ProcessSnapshot::new(build())); self.cached = Some(CachedProcessSnapshot { built_at: Instant::now(), snapshot: Arc::clone(&snapshot), }); snapshot } } fn read_process_command(pid: u32, name: &str) -> WindowsProcessCommand { let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ) else { let creation_time = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) .and_then(|process| process_creation_time(process.0)); return WindowsProcessCommand::from_cmdline(name, creation_time, None); }; let creation_time = process_creation_time(process.0); let cmdline = read_process_parameters(process.0) .and_then(|parameters| read_unicode_string(process.0, parameters.command_line)); WindowsProcessCommand::from_cmdline(name, creation_time, cmdline) } impl WindowsProcessCommand { fn from_cmdline(name: &str, creation_time: Option, cmdline: Option) -> Self { let argv = cmdline.as_deref().and_then(command_line_to_argv); let argv0 = argv .as_ref() .and_then(|argv| argv.first().cloned()) .or_else(|| (!name.is_empty()).then(|| name.to_string())); Self { creation_time, argv0, argv, cmdline, } } } fn process_is_git_bash(pid: u32) -> bool { let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else { return false; }; let Some(creation_time) = process_creation_time(process.0) else { return false; }; { let mut cache = GIT_BASH_PROCESS_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); if let Some(cached) = cache.get_mut(&pid) { if cached.creation_time == creation_time { cached.last_used = Instant::now(); return cached.is_git_bash; } } } let is_git_bash = process_executable_path(process.0) .as_deref() .is_some_and(|path| is_git_bash_executable_path(std::path::Path::new(path))); let mut cache = GIT_BASH_PROCESS_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { cache.retain(|_, cached| { cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION }); if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { cache.clear(); } } cache.insert( pid, CachedGitBashProcess { creation_time, is_git_bash, last_used: Instant::now(), }, ); is_git_bash } fn process_executable_path(process: HANDLE) -> Option { let mut path = vec![0_u16; 32_768]; let mut len = path.len() as u32; if unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &mut len) } == 0 { return None; } String::from_utf16(&path[..len as usize]).ok() } fn command_uses_git_bash(command: &portable_pty::CommandBuilder) -> bool { let Some(program) = command.get_argv().first() else { return false; }; let path = std::path::Path::new(program); if path.is_absolute() { return is_git_bash_executable_path(path); } if program.to_string_lossy().contains(['/', '\\']) { return false; } let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { return false; }; let candidate_name = if file_name.eq_ignore_ascii_case("bash") { "bash.exe" } else if file_name.eq_ignore_ascii_case("bash.exe") { file_name } else { return false; }; let search_path = command .get_env("PATH") .map(OsStr::to_os_string) .or_else(|| std::env::var_os("PATH")); search_path.is_some_and(|search_path| { std::env::split_paths(&search_path) .map(|directory| directory.join(candidate_name)) .find(|candidate| candidate.is_file()) .is_some_and(|candidate| is_git_bash_executable_path(&candidate)) }) } fn is_git_bash_executable_path(path: &std::path::Path) -> bool { let Some(file_name) = path.file_name().and_then(OsStr::to_str) else { return false; }; if !file_name.eq_ignore_ascii_case("bash.exe") || !path.is_absolute() || !path.is_file() { return false; } let Some(bin_dir) = path.parent() else { return false; }; if !bin_dir .file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.eq_ignore_ascii_case("bin")) { return false; } let Some(mut root) = bin_dir.parent() else { return false; }; if root .file_name() .and_then(OsStr::to_str) .is_some_and(|name| name.eq_ignore_ascii_case("usr")) { let Some(parent) = root.parent() else { return false; }; root = parent; } root.join("usr").join("bin").join("msys-2.0.dll").is_file() && root.join("cmd").join("git.exe").is_file() } fn process_runtime_marker(pid: u32) -> Option { let process = ProcessHandle::open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?; let creation_time = process_creation_time(process.0)?; { let mut cache = PROCESS_RUNTIME_MARKER_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); if let Some(cached) = cache.get_mut(&pid) { if cached.creation_time == creation_time && (cached.marker.is_some() || cached.cached_at.elapsed() < PROCESS_RUNTIME_MARKER_NEGATIVE_TTL) { cached.last_used = Instant::now(); return cached.marker.clone(); } } } let marker = process_runtime_marker_from_handle(process.0)?; let mut cache = PROCESS_RUNTIME_MARKER_CACHE .lock() .unwrap_or_else(|err| err.into_inner()); if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { cache.retain(|_, cached| { cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION }); if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY { cache.clear(); } } cache.insert( pid, CachedProcessRuntimeMarker { creation_time, marker: marker.clone(), cached_at: Instant::now(), last_used: Instant::now(), }, ); marker } fn process_creation_time(process: HANDLE) -> Option { let mut creation_time = FILETIME::default(); let mut exit_time = FILETIME::default(); let mut kernel_time = FILETIME::default(); let mut user_time = FILETIME::default(); if unsafe { GetProcessTimes( process, &mut creation_time, &mut exit_time, &mut kernel_time, &mut user_time, ) } == 0 { return None; } Some((u64::from(creation_time.dwHighDateTime) << 32) | u64::from(creation_time.dwLowDateTime)) } fn process_runtime_marker_from_handle(process: HANDLE) -> Option> { let parameters = read_process_parameters(process)?; let environment = read_process_environment(process, parameters.environment)?; Some(environment_variable_from_utf16( &environment, PANE_RUNTIME_MARKER_ENV_VAR, )) } fn read_process_environment(process: HANDLE, address: *const c_void) -> Option> { if address.is_null() { return None; } let mut memory = MaybeUninit::::uninit(); let queried = unsafe { VirtualQueryEx( process, address, memory.as_mut_ptr(), size_of::(), ) }; if queried == 0 { return None; } let memory = unsafe { memory.assume_init() }; let address = address as usize; let base = memory.BaseAddress as usize; let offset = address.checked_sub(base)?; let available = memory.RegionSize.checked_sub(offset)?; let read_len = available.min(MAX_PROCESS_ENVIRONMENT_BYTES); if read_len < size_of::() { return None; } let max_units = read_len / size_of::(); let chunk_units = PROCESS_ENVIRONMENT_READ_CHUNK_BYTES / size_of::(); let mut environment = Vec::new(); while environment.len() < max_units { let unit_count = (max_units - environment.len()).min(chunk_units); let chunk_bytes = unit_count * size_of::(); let mut chunk = vec![0_u16; unit_count]; let mut bytes_read = 0; let offset = environment.len().checked_mul(size_of::())?; let chunk_address = address.checked_add(offset)?; if unsafe { ReadProcessMemory( process, chunk_address as *const c_void, chunk.as_mut_ptr().cast::(), chunk_bytes, &mut bytes_read, ) } == 0 { break; } chunk.truncate(bytes_read / size_of::()); if chunk.is_empty() { break; } environment.extend_from_slice(&chunk); if let Some(end) = environment .windows(2) .position(|pair| pair == [0, 0]) .map(|index| index + 2) { environment.truncate(end); return Some(environment); } if bytes_read < chunk_bytes { break; } } None } fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option { for variable in environment.split(|unit| *unit == 0) { if variable.is_empty() { break; } let Some(separator) = variable.iter().position(|unit| *unit == u16::from(b'=')) else { continue; }; let Ok(variable_name) = String::from_utf16(&variable[..separator]) else { continue; }; if variable_name.eq_ignore_ascii_case(name) { return String::from_utf16(&variable[separator + 1..]).ok(); } } None } fn read_process_parameters(process: HANDLE) -> Option { let mut basic_info = MaybeUninit::::uninit(); let status = unsafe { NtQueryInformationProcess( process, ProcessBasicInformation, basic_info.as_mut_ptr().cast::(), size_of::() as u32, null_mut(), ) }; if status != STATUS_SUCCESS as NTSTATUS { return None; } let basic_info = unsafe { basic_info.assume_init() }; if basic_info.PebBaseAddress.is_null() { return None; } let peb = read_process_value::(process, basic_info.PebBaseAddress.cast::())?; if peb.process_parameters.is_null() { return None; } read_process_value::(process, peb.process_parameters.cast()) } fn command_line_to_argv(command_line: &str) -> Option> { let wide: Vec = command_line .encode_utf16() .chain(std::iter::once(0)) .collect(); let mut argc = 0; let argv_ptr = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut argc) }; if argv_ptr.is_null() || argc <= 0 { return None; } let argv_slice = unsafe { std::slice::from_raw_parts(argv_ptr, argc as usize) }; let mut argv = Vec::with_capacity(argc as usize); for &arg in argv_slice { if arg.is_null() { continue; } let mut len = 0; unsafe { while *arg.add(len) != 0 { len += 1; } argv.push(String::from_utf16_lossy(std::slice::from_raw_parts( arg, len, ))); } } unsafe { LocalFree(argv_ptr.cast()); } Some(argv) } fn nul_terminated_utf16_to_string(buffer: &[u16]) -> String { let len = buffer .iter() .position(|&value| value == 0) .unwrap_or(buffer.len()); String::from_utf16_lossy(&buffer[..len]) } pub fn session_processes(child_pid: u32) -> Vec { if child_pid == 0 { return Vec::new(); } let snapshot = ProcessSnapshot::new(snapshot_processes()); session_processes_from_snapshot(child_pid, &snapshot) } fn session_processes_from_snapshot(child_pid: u32, snapshot: &ProcessSnapshot) -> Vec { if snapshot.entry(child_pid).is_none() { return Vec::new(); } let mut pids = vec![child_pid]; pids.extend( descendant_entries(child_pid, snapshot) .into_iter() .map(|entry| entry.pid), ); pids } pub fn signal_processes(pids: &[u32], signal: Signal) { if signal == Signal::Hangup { return; } for &pid in pids { let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else { continue; }; unsafe { TerminateProcess(process.0, 1); } } } pub fn process_exists(pid: u32) -> bool { let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else { return false; }; let mut exit_code = 0; let ok = unsafe { GetExitCodeProcess(process.0, &mut exit_code) } != 0; ok && exit_code == STILL_ACTIVE } pub fn write_clipboard(bytes: &[u8]) -> bool { let Ok(text) = std::str::from_utf8(bytes) else { return false; }; if text.contains('\0') { return false; } let mut utf16: Vec = text.encode_utf16().collect(); utf16.push(0); let Some(byte_len) = utf16.len().checked_mul(size_of::()) else { return false; }; unsafe { let owner = GetConsoleWindow(); if owner.is_null() || OpenClipboard(owner) == 0 { return false; } let _clipboard = ClipboardGuard; if EmptyClipboard() == 0 { return false; } let memory = GlobalAlloc(GMEM_MOVEABLE, byte_len); if memory.is_null() { return false; } let locked = GlobalLock(memory); if locked.is_null() { GlobalFree(memory); return false; } copy_nonoverlapping(utf16.as_ptr(), locked.cast::(), utf16.len()); GlobalUnlock(memory); if SetClipboardData(CF_UNICODETEXT as u32, memory).is_null() { GlobalFree(memory); return false; } true } } pub fn read_clipboard_text() -> Option { None } pub fn open_url(url: &str) -> std::io::Result> { let operation = wide_null("open"); let url = wide_null(url); let result = unsafe { ShellExecuteW( std::ptr::null_mut(), operation.as_ptr(), url.as_ptr(), std::ptr::null(), std::ptr::null(), 1, ) }; if result as isize > 32 { Ok(None) } else { Err(std::io::Error::other(format!( "failed to open URL with ShellExecuteW: code {}", result as isize ))) } } pub fn read_clipboard_image() -> Option { for attempt in 0..10 { if unsafe { OpenClipboard(null_mut()) } != 0 { let _clipboard = ClipboardGuard; if let Some(bytes) = read_registered_png_clipboard() { return Some(ClipboardImage { bytes, extension: "png", }); } for format in [CF_DIBV5 as u32, CF_DIB as u32] { if let Some(bytes) = clipboard_global_bytes(format, clipboard_image::MAX_CLIPBOARD_ALLOCATION) { if let Some(bytes) = clipboard_image::dib_to_png(&bytes) { return Some(ClipboardImage { bytes, extension: "png", }); } } } return None; } if attempt < 9 { std::thread::sleep(Duration::from_millis(5)); } } None } fn read_registered_png_clipboard() -> Option> { static PNG_FORMAT: LazyLock = LazyLock::new(|| { let name = wide_null("PNG"); unsafe { RegisterClipboardFormatW(name.as_ptr()) } }); if *PNG_FORMAT == 0 { return None; } let bytes = clipboard_global_bytes( *PNG_FORMAT, crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD + 64 * 1024, )?; clipboard_image::validated_png(&bytes) } fn clipboard_global_bytes(format: u32, max_bytes: usize) -> Option> { let handle = unsafe { GetClipboardData(format) }; if handle.is_null() { return None; } let data = unsafe { GlobalLock(handle) }; if data.is_null() { return None; } let size = unsafe { GlobalSize(handle) }; if size == 0 || size > max_bytes { unsafe { GlobalUnlock(handle); } return None; } let mut bytes = vec![0_u8; size]; unsafe { copy_nonoverlapping(data.cast::(), bytes.as_mut_ptr(), size); GlobalUnlock(handle); } Some(bytes) } pub fn show_desktop_notification(title: &str, body: Option<&str>) -> std::io::Result { let title = title.to_owned(); let body = body.unwrap_or(&title).to_owned(); let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1); std::thread::Builder::new() .name("herdr-windows-notification".into()) .spawn(move || show_desktop_notification_on_thread(&title, &body, ready_tx))?; ready_rx .recv_timeout(Duration::from_secs(2)) .map_err(|err| match err { std::sync::mpsc::RecvTimeoutError::Timeout => std::io::Error::new( std::io::ErrorKind::TimedOut, "Windows notification setup timed out", ), std::sync::mpsc::RecvTimeoutError::Disconnected => std::io::Error::other( "Windows notification thread exited before reporting readiness", ), })? } fn show_desktop_notification_on_thread( title: &str, body: &str, ready_tx: std::sync::mpsc::SyncSender>, ) { let class_name = wide_null("STATIC"); let window_name = wide_null("Herdr notifications"); let hwnd = unsafe { CreateWindowExW( 0, class_name.as_ptr(), window_name.as_ptr(), 0, 0, 0, 0, 0, null_mut(), null_mut(), null_mut(), std::ptr::null(), ) }; if hwnd.is_null() { let _ = ready_tx.send(Err(std::io::Error::last_os_error())); return; } let mut notification = unsafe { std::mem::zeroed::() }; notification.cbSize = size_of::() as u32; notification.hWnd = hwnd; notification.uID = 1; notification.hIcon = unsafe { LoadIconW(null_mut(), IDI_APPLICATION) }; notification.uFlags = NIF_TIP; if !notification.hIcon.is_null() { notification.uFlags |= NIF_ICON; } copy_wide_truncated(&mut notification.szTip, "Herdr"); if unsafe { Shell_NotifyIconW(NIM_ADD, ¬ification) } == 0 { let _ = ready_tx.send(Err(std::io::Error::other( "failed to add Herdr notification-area icon", ))); unsafe { DestroyWindow(hwnd); } return; } notification.uFlags = NIF_INFO; notification.dwInfoFlags = NIIF_INFO | NIIF_NOSOUND; copy_wide_truncated(&mut notification.szInfoTitle, title); copy_wide_truncated(&mut notification.szInfo, body); if unsafe { Shell_NotifyIconW(NIM_MODIFY, ¬ification) } == 0 { unsafe { Shell_NotifyIconW(NIM_DELETE, ¬ification); DestroyWindow(hwnd); } let _ = ready_tx.send(Err(std::io::Error::other( "failed to show Herdr desktop notification", ))); return; } let _ = ready_tx.send(Ok(true)); std::thread::sleep(Duration::from_secs(10)); unsafe { Shell_NotifyIconW(NIM_DELETE, ¬ification); DestroyWindow(hwnd); } } fn copy_wide_truncated(destination: &mut [u16; N], value: &str) { destination.fill(0); let mut offset = 0; for ch in value.chars() { let mut units = [0; 2]; let encoded = ch.encode_utf16(&mut units); if offset + encoded.len() >= N { break; } destination[offset..offset + encoded.len()].copy_from_slice(encoded); offset += encoded.len(); } } fn wide_null(value: &str) -> Vec { value.encode_utf16().chain(std::iter::once(0)).collect() } struct ProcessHandle(HANDLE); struct ClipboardGuard; impl Drop for ClipboardGuard { fn drop(&mut self) { unsafe { CloseClipboard(); } } } impl ProcessHandle { fn open(pid: u32, access: u32) -> Option { if pid == 0 { return None; } let handle = unsafe { OpenProcess(access, 0, pid) }; (!handle.is_null()).then_some(Self(handle)) } } impl Drop for ProcessHandle { fn drop(&mut self) { unsafe { CloseHandle(self.0); } } } #[repr(C)] #[derive(Clone, Copy)] struct Peb { reserved1: [u8; 2], being_debugged: u8, reserved2: [u8; 1], reserved3: [*mut c_void; 2], ldr: *mut c_void, process_parameters: *mut RtlUserProcessParameters, } #[repr(C)] #[derive(Clone, Copy)] struct CurDir { dos_path: UNICODE_STRING, handle: HANDLE, } #[repr(C)] #[derive(Clone, Copy)] struct RtlUserProcessParameters { maximum_length: u32, length: u32, flags: u32, debug_flags: u32, console_handle: HANDLE, console_flags: u32, standard_input: HANDLE, standard_output: HANDLE, standard_error: HANDLE, current_directory: CurDir, dll_path: UNICODE_STRING, image_path_name: UNICODE_STRING, command_line: UNICODE_STRING, environment: *mut c_void, } fn read_process_value(process: HANDLE, address: *const c_void) -> Option { if address.is_null() { return None; } let mut value = MaybeUninit::::uninit(); let mut bytes_read = 0; let ok = unsafe { ReadProcessMemory( process, address, value.as_mut_ptr().cast::(), size_of::(), &mut bytes_read, ) } != 0; (ok && bytes_read == size_of::()).then(|| unsafe { value.assume_init() }) } fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option { if unicode.Buffer.is_null() || unicode.Length == 0 || !unicode.Length.is_multiple_of(2) { return None; } let char_len = usize::from(unicode.Length / 2); let mut buffer = vec![0_u16; char_len]; let mut bytes_read = 0; let ok = unsafe { ReadProcessMemory( process, unicode.Buffer.cast::(), buffer.as_mut_ptr().cast::(), usize::from(unicode.Length), &mut bytes_read, ) } != 0; if !ok || bytes_read != usize::from(unicode.Length) { return None; } String::from_utf16(&buffer).ok() } // Prefix-mode ASCII input source support (see `switch_ascii_input_source_in_prefix`). // // Windows IMEs live in the terminal-emulator process, not in herdr. Empirically: // - `WM_IME_CONTROL` / `IMC_GETOPENSTATUS` reads whether the IME is open // (composing native characters) reliably across the process boundary (this // is what kren-select uses), so we detect state with it. The read goes // through `SendMessageTimeoutW` (`SMTO_ABORTIFHUNG`) so a hung host process // cannot block us indefinitely. // - Writing the state back (`IMC_SETOPENSTATUS` / `IMC_SETCONVERSIONMODE`) // changes the flag value but does NOT affect real input in terminal/TSF // hosts, so we cannot switch by writing the mode. // - `ImmGetContext` on the foreground window returns null across the process // boundary, so the ImmGetOpenStatus/ImmSetOpenStatus path is unavailable. // Therefore we switch the way kren-select does: inject the IME toggle key with // `SendInput`, which reaches the foreground input queue like a real keypress. // // The toggle key is language-specific, so we pick it from the foreground // keyboard layout's language id. Only Korean is mapped today; other IMEs are // detected and left untouched (a no-op) rather than toggled with the wrong key. /// `WM_IME_CONTROL` sub-command that reads whether the IME is open, i.e. /// composing native characters. This is `IMC_GETOPENSTATUS` (0x0005); for the /// Korean IME "open" is exactly the Hangul state and "closed" is English/ASCII /// direct input, which is the state we detect and toggle. const IMC_GETOPENSTATUS: usize = 0x0005; /// Virtual key that toggles Hangul/English on Korean IMEs. const VK_HANGUL: u16 = 0x15; /// Primary language id (low 10 bits of a LANGID) for Korean. const LANG_KOREAN: u32 = 0x12; /// Whether the IME reports itself open, i.e. composing native characters /// (Hangul for the Korean IME). `IMC_GETOPENSTATUS` returns nonzero when the /// IME is open and zero when it is in direct English/ASCII input. fn ime_open(open_status: isize) -> bool { open_status != 0 } /// Timeout (ms) for the cross-process IME open-status read. Short enough that a /// hung terminal never freezes prefix-mode entry/exit. const IME_STATUS_READ_TIMEOUT_MS: u32 = 200; /// Reads the IME open status (`IMC_GETOPENSTATUS`) with a bounded timeout. /// /// `WM_IME_CONTROL` crosses into the terminal-emulator process, and a plain /// `SendMessageW` would block herdr's client thread until that process responds /// (indefinitely if it is hung). `SendMessageTimeoutW` with `SMTO_ABORTIFHUNG` /// caps the wait; on timeout or failure this returns `None` and callers leave /// the IME untouched rather than blocking or guessing. fn read_ime_open_status(ime_hwnd: HWND) -> Option { let mut result: usize = 0; // SAFETY: `ime_hwnd` is a non-null IME window from `ImmGetDefaultIMEWnd`, and // `result` is a valid out-pointer for the message's `DWORD_PTR` result. let ret = unsafe { SendMessageTimeoutW( ime_hwnd, WM_IME_CONTROL, IMC_GETOPENSTATUS, 0, SMTO_ABORTIFHUNG, IME_STATUS_READ_TIMEOUT_MS, &mut result, ) }; if ret == 0 { // Timed out or failed; do not block or assume a state. return None; } Some(result as isize) } /// The IME toggle key for a keyboard layout language id, or `None` when the /// language's toggle key is not known. `langid` is the full LANGID (LOWORD of /// an `HKL`); the primary language is its low 10 bits. /// /// Only Korean is mapped: `VK_HANGUL` is the Hangul/English toggle. Japanese /// (half/full-width) and Chinese use different keys per IME, so they return /// `None` and are left untouched instead of toggled incorrectly. fn toggle_key_for_language(langid: u32) -> Option { match langid & 0x3FF { LANG_KOREAN => Some(VK_HANGUL), _ => None, } } /// Builds the key-down then key-up `INPUT` pair for `vk`. fn key_tap_inputs(vk: u16) -> [INPUT; 2] { let key_event = |flags| INPUT { r#type: INPUT_KEYBOARD, Anonymous: INPUT_0 { ki: KEYBDINPUT { wVk: vk, wScan: 0, dwFlags: flags, time: 0, dwExtraInfo: 0, }, }, }; [key_event(0), key_event(KEYEVENTF_KEYUP)] } /// Injects a key-down then key-up for `vk` via `SendInput`. /// /// Returns `true` when the key-down was queued and the IME may have toggled. /// Thin wrapper over [`send_vk_tap_with`] that plugs in the real `SendInput`; /// the injection policy lives there so it can be unit-tested without the OS. fn send_vk_tap(vk: u16) -> bool { send_vk_tap_with(vk, |events| { // SAFETY: `events` outlives the call; its `INPUT_KEYBOARD` entries have // the `ki` union variant fully initialized, which is the variant // SendInput reads for keyboard input. `size_of::()` is the // required `cbSize`. unsafe { SendInput( events.len() as u32, events.as_ptr(), size_of::() as i32, ) } }) } /// Core key-tap logic with the raw event injector abstracted behind `inject`, /// which returns how many of the passed events it actually queued. This keeps /// the success / partial-injection / total-failure branches unit-testable /// without touching the real `SendInput`. /// /// `SendInput` returns how many events it queued; a short count means injection /// was blocked (e.g. by UIPI). Returns `true` whenever the key-down was queued, /// because the IME may have toggled and callers must retain restoration state. /// When only the key-down landed, the key-up is retried so the key is not left /// logically held down. fn send_vk_tap_with(vk: u16, mut inject: impl FnMut(&[INPUT]) -> u32) -> bool { let inputs = key_tap_inputs(vk); let sent = inject(&inputs); if sent as usize == inputs.len() { return true; } if sent == 1 { // The key-down landed and may already have toggled the IME. Retry the // dropped key-up, but report that restoration state is still required. let key_up = [inputs[1]]; let up_sent = inject(&key_up); tracing::warn!( vk, sent, expected = inputs.len(), key_up_retry_sent = up_sent, "SendInput dropped the IME toggle key-up; retried key-up" ); return true; } tracing::warn!( vk, sent, expected = inputs.len(), "SendInput did not inject the IME toggle key tap" ); false } pub(crate) fn pump_input_source_runloop() {} /// Switch the foreground window's IME to ASCII-capable input for prefix mode. /// /// Returns `None` (nothing to restore) when there is no foreground IME, the /// keyboard language has no known toggle key, or the IME is already /// ASCII-capable, matching the macOS contract. pub(crate) fn switch_to_ascii_input_source() -> Option { // SAFETY: all calls are Win32 UI functions invoked on the client's main // thread. Every HWND is null-checked before use; `fg_thread` is a thread id // (not a handle) used only as `GetKeyboardLayout` input, where 0 harmlessly // falls back to the calling thread's layout. unsafe { let fg = GetForegroundWindow(); if fg.is_null() { return None; } // Pick the toggle key for the foreground keyboard language. Unknown // languages (Japanese, Chinese, ...) are left untouched. let fg_thread = GetWindowThreadProcessId(fg, null_mut()); let langid = (GetKeyboardLayout(fg_thread) as usize as u32) & 0xFFFF; let Some(toggle_vk) = toggle_key_for_language(langid) else { tracing::debug!( langid = format!("{langid:#06x}"), "prefix IME switch: no toggle key for keyboard language, leaving IME as-is" ); return None; }; // Detect the open (Hangul) state via the bounded read path. let ime_hwnd = ImmGetDefaultIMEWnd(fg); if ime_hwnd.is_null() { return None; } let Some(open) = read_ime_open_status(ime_hwnd) else { tracing::debug!("prefix IME switch skipped: IME open-status read timed out"); return None; }; if !ime_open(open) { // Already in English/ASCII input; nothing to switch or restore. return None; } // The bounded cross-process status read can take long enough for focus // to change. Recheck immediately before using the global input queue. if GetForegroundWindow() != fg { tracing::debug!("prefix IME switch skipped: foreground window changed"); return None; } // Toggle to ASCII by injecting the language's IME toggle key. Only arm // restoration when the toggle actually landed, so we never try to // restore a switch that never happened. if !send_vk_tap(toggle_vk) { tracing::warn!( langid = format!("{langid:#06x}"), "prefix IME switch: toggle injection failed, leaving IME as-is" ); return None; } tracing::debug!( langid = format!("{langid:#06x}"), "switched host IME to ASCII for prefix mode" ); Some(InputSourceRestore { toggle_vk, origin_hwnd: fg as isize, }) } } /// Restores the native (Hangul) IME state that was active before prefix mode. /// /// Only constructed by [`switch_to_ascii_input_source`] after it successfully /// toggled the IME to English/ASCII. Dropping it re-injects the same toggle key /// to go back, but only after two guards, so restoration never fights the user /// or another application: /// - the same window that was switched must still be focused, otherwise the /// toggle would land on whatever app the user moved to; /// - the IME must still be in English (our switch still in effect), otherwise /// the user manually returned to Hangul during prefix mode and we must leave /// their choice alone. /// /// `origin_hwnd` stores the foreground window at switch time as raw pointer bits /// (`isize`, not `HWND`) so the guard stays `Send` when parked in the client's /// prefix-input state across `.await` points. #[derive(Debug)] pub(crate) struct InputSourceRestore { toggle_vk: u16, origin_hwnd: isize, } impl Drop for InputSourceRestore { fn drop(&mut self) { // SAFETY: all calls are Win32 UI functions invoked on the client's main // thread. Every HWND is null-checked before use. unsafe { // Guard 1: only restore if the window we switched is still focused, // so the toggle never lands on a different application. let fg = GetForegroundWindow(); if fg.is_null() || fg as isize != self.origin_hwnd { tracing::debug!( "prefix IME restore skipped: foreground window changed since switch" ); return; } // Guard 2: only restore if the IME is still in English (our switch is // still in effect). If the user manually switched back to Hangul // during prefix mode, leave their choice untouched. let ime_hwnd = ImmGetDefaultIMEWnd(fg); if ime_hwnd.is_null() { return; } let Some(open) = read_ime_open_status(ime_hwnd) else { tracing::debug!("prefix IME restore skipped: IME open-status read timed out"); return; }; if ime_open(open) { tracing::debug!("prefix IME restore skipped: IME already back to native input"); return; } // The bounded cross-process status read can take long enough for // focus to change. Recheck immediately before using SendInput. if GetForegroundWindow() != fg { tracing::debug!("prefix IME restore skipped: foreground window changed"); return; } if send_vk_tap(self.toggle_vk) { tracing::debug!("restored host IME after prefix mode"); } } } } #[cfg(test)] mod tests { use std::{ fs, process::{Command, Stdio}, sync::Arc, thread, time::{Duration, Instant}, }; use windows_sys::Win32::System::Console::{ AllocConsole, FreeConsole, GetConsoleProcessList, GetConsoleWindow, }; #[test] fn private_remote_directory_supports_long_paths() { let base = std::env::temp_dir().join(format!( "herdr-private-remote-dir-test-{}", std::process::id() )); fs::create_dir_all(&base).expect("create test base"); let private = base.join("x".repeat(240)); super::create_remote_private_dir(&private).expect("create private long-path directory"); fs::write(private.join("probe"), b"ok").expect("write inherited private file"); fs::remove_dir_all(base).expect("remove test directory"); } #[test] fn windows_conpty_native_encoder_uses_canonical_phase_and_repeat_count() { let key = crate::input::TerminalKey::new( crossterm::event::KeyCode::Char('7'), crossterm::event::KeyModifiers::CONTROL, ) .with_windows_record(crate::input::WindowsKeyRecord { key_down: true, repeat_count: 3, virtual_key_code: 0x37, virtual_scan_code: 0x08, unicode: 0, control_key_state: 0x0008, }); assert_eq!( super::encode_windows_conpty_fallback(&key), Some(b"\x1b[55;8;0;1;8;3_".to_vec()) ); let mut release = key.with_kind(crossterm::event::KeyEventKind::Release); release.repeat_count = 3; assert_eq!( super::encode_windows_conpty_fallback(&release), Some(b"\x1b[55;8;0;0;8;1_".to_vec()) ); } #[test] fn windows_conpty_native_encoder_preserves_semantic_escape_fallback() { let escape = crate::input::TerminalKey::new( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::empty(), ); assert_eq!( super::encode_windows_conpty_fallback(&escape), Some(b"\x1b[27;1;27;1;0;1_\x1b[27;1;27;0;0;1_".to_vec()) ); assert_eq!( super::encode_windows_conpty_fallback( &escape .clone() .with_kind(crossterm::event::KeyEventKind::Repeat), ), None ); assert_eq!( super::encode_windows_conpty_fallback( &escape .clone() .with_kind(crossterm::event::KeyEventKind::Release), ), None ); assert_eq!( super::encode_windows_conpty_fallback(&escape.clone().with_vt_bytes(vec![27])), None ); assert_eq!( super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new( crossterm::event::KeyCode::Esc, crossterm::event::KeyModifiers::ALT, ),), None ); } #[test] fn windows_conpty_native_encoder_preserves_semantic_shift_enter_fallback() { let shift_enter = crate::input::TerminalKey::new( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::SHIFT, ); assert_eq!( super::encode_windows_conpty_fallback(&shift_enter), Some(b"\x1b[13;28;13;1;16;1_".to_vec()) ); assert_eq!( super::encode_windows_conpty_fallback(&crate::input::TerminalKey::new( crossterm::event::KeyCode::Enter, crossterm::event::KeyModifiers::empty(), )), None ); } #[test] fn windows_notification_text_is_null_terminated_and_unicode_safe() { let mut destination = [u16::MAX; 6]; super::copy_wide_truncated(&mut destination, "abc😀def"); assert_eq!(String::from_utf16(&destination[..5]).unwrap(), "abc😀"); assert_eq!(destination[5], 0); } #[test] fn powershell_agent_command_omits_argument_list_when_no_arguments_are_passed() { let argv = vec!["opencode".into()]; assert_eq!( super::interactive_shell_command(&argv, "powershell.exe").as_deref(), Some("& opencode") ); } #[test] fn cmd_agent_command_encodes_edge_arguments_without_cmd_expansion() { use base64::Engine as _; assert_eq!(super::super::quote_powershell_arg("@options"), "'@options'"); let argv = vec![ "pi".into(), String::new(), "two words".into(), "100%".into(), "wow!".into(), "a'b".into(), "--model".into(), ]; let command = super::interactive_shell_command(&argv, "cmd.exe").unwrap(); let encoded = command.split_whitespace().last().unwrap(); let bytes = base64::engine::general_purpose::STANDARD .decode(encoded) .unwrap(); let utf16 = bytes .chunks_exact(2) .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect::>(); assert_eq!( String::from_utf16(&utf16).unwrap(), "if((Get-Command pi -ErrorAction SilentlyContinue).CommandType -eq 'ExternalScript'){& pi '' 'two words' '100%' 'wow!' 'a''b' '--model'}else{Start-Process -FilePath pi -ArgumentList '\"\" \"two words\" 100% wow! a''b --model' -NoNewWindow -Wait}" ); } #[test] fn windows_shells_round_trip_agent_arguments_through_a_real_command() { let _lock = crate::integration::integration_env_lock(); let base = std::env::temp_dir().join(format!( "herdr-agent-argv-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() )); fs::create_dir_all(&base).unwrap(); let helper = base.join("pi.cmd"); fs::write( &helper, "@echo off\r\n>\"%HERDR_ARGV_CAPTURE%\" (\r\necho(%~1\r\necho(%~2\r\necho(%~3\r\necho(%~4\r\necho(%~5\r\necho(%~6\r\necho(%~7\r\n)\r\n", ) .unwrap(); let argv = vec![ "pi".into(), String::new(), "two words".into(), "100%".into(), "wow!".into(), "a'b".into(), "@options".into(), "--model".into(), ]; let inherited_path = std::env::var_os("PATH").unwrap_or_default(); let path = format!("{};{}", base.display(), inherited_path.to_string_lossy()); let run_command = |shell: &str, command: &str, capture: &std::path::Path| { let mut process = if shell == "cmd.exe" { let mut process = Command::new("cmd.exe"); process.args(["/d", "/c", command]); process } else { let mut process = Command::new("powershell.exe"); process.args(["-NoLogo", "-NoProfile", "-Command", command]); process }; process .env("PATH", &path) .env("HERDR_ARGV_CAPTURE", capture) .env("PSExecutionPolicyPreference", "Bypass") .status() .unwrap() }; for shell in ["powershell.exe", "cmd.exe"] { let no_args_capture = base.join(format!("{shell}-no-args.txt")); let no_args_command = super::interactive_shell_command(&["pi".into()], shell).unwrap(); let status = run_command(shell, &no_args_command, &no_args_capture); assert!(status.success(), "{shell} argument-free command failed"); assert_eq!( fs::read_to_string(no_args_capture) .unwrap() .replace("\r\n", "\n"), "\n\n\n\n\n\n\n" ); let capture = base.join(format!("{shell}.txt")); let command = super::interactive_shell_command(&argv, shell).unwrap(); let status = run_command(shell, &command, &capture); assert!(status.success(), "{shell} command failed"); assert_eq!( fs::read_to_string(capture).unwrap().replace("\r\n", "\n"), "\ntwo words\n100%\nwow!\na'b\n@options\n--model\n" ); } fs::remove_file(helper).unwrap(); fs::write( base.join("pi.ps1"), "Set-Content -LiteralPath $env:HERDR_ARGV_CAPTURE -Value @(\"$($args[0])\", \"$($args[1])\", \"$($args[2])\", \"$($args[3])\", \"$($args[4])\", \"$($args[5])\", \"$($args[6])\")\r\n", ) .unwrap(); for shell in ["powershell.exe", "cmd.exe"] { let capture = base.join(format!("{shell}-ps1.txt")); let command = super::interactive_shell_command(&argv, shell).unwrap(); let status = run_command(shell, &command, &capture); assert!(status.success(), "{shell} PowerShell script command failed"); assert_eq!( fs::read_to_string(capture).unwrap().replace("\r\n", "\n"), "\ntwo words\n100%\nwow!\na'b\n@options\n--model\n" ); } let _ = fs::remove_dir_all(base); } const CONSOLE_TEST_CHILD_ENV: &str = "HERDR_TEST_CONSOLE_CHILD_MODE"; const CONSOLE_TEST_PARENT_PID_ENV: &str = "HERDR_TEST_CONSOLE_PARENT_PID"; const WMI_DAEMON_TEST_CHILD_ENV: &str = "HERDR_TEST_WMI_DAEMON_CHILD"; #[test] fn windows_environment_keys_use_unicode_case_insensitive_ordering() { assert_eq!( super::windows_environment_key_cmp("hérdr", "HÉRDR"), std::cmp::Ordering::Equal ); } #[test] fn windows_wmi_daemon_preserves_environment_and_working_directory() { if let Some(capture) = std::env::var_os(WMI_DAEMON_TEST_CHILD_ENV) { let cwd = std::env::current_dir().expect("WMI daemon test working directory"); fs::write( capture, format!( "{}\n{}\n{}", cwd.display(), unsafe { GetConsoleWindow() }.is_null(), !super::current_job_kills_processes_on_close().expect("inspect WMI daemon job") ), ) .expect("write WMI daemon test capture"); return; } let base = std::env::temp_dir().join(format!( "herdr-wmi-daemon-test-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_millis() )); fs::create_dir_all(&base).unwrap(); let capture = base.join("capture.txt"); let test_exe = std::env::current_exe().expect("resolve test executable"); let mut child = Command::new(test_exe); child .arg("windows_wmi_daemon_preserves_environment_and_working_directory") .current_dir(&base) .env(WMI_DAEMON_TEST_CHILD_ENV, &capture) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); let pid = super::launch_server_daemon_with_wmi(&child) .expect("launch detached process through WMI"); assert_ne!(pid, 0, "WMI returned an invalid process id"); let expected = format!("{}\ntrue\ntrue", base.display()); let deadline = Instant::now() + Duration::from_secs(10); loop { if fs::read_to_string(&capture).is_ok_and(|captured| captured == expected) { break; } assert!( Instant::now() < deadline, "WMI daemon child did not write the expected capture" ); thread::sleep(Duration::from_millis(50)); } let _ = fs::remove_dir_all(base); } fn console_process_ids() -> Vec { let mut process_ids = vec![0; 8]; loop { let count = unsafe { GetConsoleProcessList(process_ids.as_mut_ptr(), process_ids.len() as u32) } as usize; if count == 0 { return Vec::new(); } if count <= process_ids.len() { process_ids.truncate(count); return process_ids; } process_ids.resize(count, 0); } } #[test] fn windows_background_and_server_daemon_commands_do_not_have_consoles() { if let Some(mode) = std::env::var_os(CONSOLE_TEST_CHILD_ENV) { assert!( unsafe { GetConsoleWindow() }.is_null(), "{} child opened or inherited a console window", mode.to_string_lossy() ); let parent_pid = std::env::var(CONSOLE_TEST_PARENT_PID_ENV) .expect("console test parent pid") .parse::() .expect("numeric console test parent pid"); assert!( !console_process_ids().contains(&parent_pid), "{} child inherited the parent console", mode.to_string_lossy() ); return; } let allocated_console = if console_process_ids().is_empty() { assert_ne!(unsafe { AllocConsole() }, 0, "allocate test console"); true } else { false }; let parent_pid = std::process::id().to_string(); let test_exe = std::env::current_exe().expect("resolve test executable"); let configurations: [(&str, fn(&mut Command)); 2] = [ ("background", super::configure_background_command_platform), ("server daemon", super::detach_server_daemon_command), ]; for (mode, configure) in configurations { let mut child = Command::new(&test_exe); child .arg("windows_background_and_server_daemon_commands_do_not_have_consoles") .env(CONSOLE_TEST_CHILD_ENV, mode) .env(CONSOLE_TEST_PARENT_PID_ENV, &parent_pid) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); configure(&mut child); let status = child.status().expect("spawn console isolation test child"); assert!( status.success(), "{mode} child opened or inherited a console" ); } let command = format!( r#""{}" windows_background_and_server_daemon_commands_do_not_have_consoles"#, test_exe.display() ); let status = crate::platform::detached_custom_command_process(&command) .env(CONSOLE_TEST_CHILD_ENV, "detached custom command descendant") .env(CONSOLE_TEST_PARENT_PID_ENV, &parent_pid) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .expect("spawn detached custom command test child"); assert!( status.success(), "detached custom command descendant opened or inherited a console" ); if allocated_console { unsafe { FreeConsole(); } } } fn argv_strings(argv: &[std::ffi::OsString]) -> Vec { argv.into_iter() .map(|arg| arg.to_string_lossy().into_owned()) .collect() } #[test] fn pane_custom_command_uses_cmd() { let builder = super::pane_custom_command_pty_builder_with_comspec( "echo hello", Some(r"C:\Windows\System32\cmd.exe".into()), ); assert_eq!( argv_strings(builder.get_argv()), [r"C:\Windows\System32\cmd.exe", "/d", "/c"] ); } #[test] fn detached_custom_command_uses_cmd() { let expected_shell = std::env::var_os("ComSpec") .filter(|value| !value.is_empty()) .unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()) .to_string_lossy() .into_owned(); let process = super::detached_custom_command_process_platform("echo hello"); assert_eq!(process.get_program().to_string_lossy(), expected_shell); assert_eq!( process .get_args() .map(|arg| arg.to_string_lossy().into_owned()) .collect::>(), ["/d", "/c", "echo hello"] ); } #[test] fn custom_command_falls_back_when_comspec_is_empty() { let builder = super::pane_custom_command_pty_builder_with_comspec("echo hello", Some("".into())); assert_eq!( argv_strings(builder.get_argv()), [r"C:\Windows\System32\cmd.exe", "/d", "/c"] ); } #[test] fn detached_custom_command_preserves_quoted_command_tail() { let path = std::env::temp_dir().join(format!( "herdr-raw-command-quotes-{}.txt", std::process::id() )); let command = format!(r#"echo "hi" > "{}""#, path.display()); let status = super::detached_custom_command_process_platform(&command) .status() .expect("spawn raw command"); assert!(status.success(), "{status:?}"); let content = std::fs::read_to_string(&path).expect("read command output"); let _ = std::fs::remove_file(&path); assert!(content.contains(r#""hi""#), "{content:?}"); assert!(!content.contains(r#"\"hi\""#), "{content:?}"); } #[test] fn windows_process_cwd_reads_child_launch_directory() { let cwd = std::env::temp_dir().join(format!("herdr-cwd-test-{}", std::process::id())); fs::create_dir_all(&cwd).expect("create cwd fixture"); let shell = std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); let mut child = Command::new(shell) .args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"]) .current_dir(&cwd) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn cmd"); let deadline = Instant::now() + Duration::from_secs(5); let mut observed = None; while Instant::now() < deadline { observed = super::process_cwd(child.id()); if observed.as_deref() == Some(cwd.as_path()) { break; } thread::sleep(Duration::from_millis(100)); } let _ = child.kill(); let _ = child.wait(); let _ = fs::remove_dir_all(&cwd); assert_eq!(observed.as_deref(), Some(cwd.as_path())); } #[test] fn windows_process_environment_reads_runtime_marker() { let shell = std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into()); let mut child = Command::new(shell) .args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"]) .env(super::PANE_RUNTIME_MARKER_ENV_VAR, "pane-test") .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .expect("spawn cmd"); let deadline = Instant::now() + Duration::from_secs(5); let mut observed = None; while Instant::now() < deadline { observed = super::process_runtime_marker(child.id()); if observed.as_deref() == Some("pane-test") { break; } thread::sleep(Duration::from_millis(100)); } let _ = child.kill(); let _ = child.wait(); assert_eq!(observed.as_deref(), Some("pane-test")); } #[test] fn windows_process_tree_selects_direct_agent_descendant() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| panic!("Git Bash fallback must not run after normal detection succeeds"), |_| panic!("runtime marker must not be read after normal detection succeeds"), ) .unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes.len(), 1); assert_eq!(job.processes[0].name, "codex.exe"); } #[test] fn windows_process_tree_still_inspects_unusual_escaped_argv0() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry(20, 99, "launcher.exe", &["codex.exe"]), ]); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |_| Some("pane-a".to_string()), ) .unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "launcher.exe"); } #[test] fn windows_process_tree_still_inspects_unusual_descendant_argv0() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "launcher.exe", &["codex.exe"]), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "launcher.exe"); } #[test] fn windows_process_tree_shares_snapshot_candidates_across_git_bash_panes() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry( 11, 10, "bash.exe", &[r"C:\Program Files\Git\usr\bin\bash.exe"], ), test_entry(12, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry( 20, 99, "sh.exe", &[r"C:\Program Files\Git\usr\bin\sh.exe", "/c/npm/codex"], ), test_entry( 30, 20, "node.exe", &[ r"C:\Program Files\nodejs\node.exe", r"C:\Users\user\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js", ], ), test_entry( 40, 30, "codex.exe", &[r"C:\npm\node_modules\@openai\codex\bin\codex.exe"], ), test_entry(50, 98, "claude.exe", &["claude.exe"]), ]); let mut inspected = Vec::new(); assert!(snapshot.agent_indices.get().is_none()); let marker = |entry: &super::WindowsProcessEntry| match entry.pid { 12 | 50 => Some("pane-b".to_string()), _ => Some("pane-a".to_string()), }; let first = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |entry| { inspected.push(entry.pid); marker(entry) }, ) .unwrap(); let indices = snapshot.agent_indices.get().unwrap(); let second = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 12, &snapshot, |_| true, marker, ) .unwrap(); assert_eq!(first.process_group_id, 20); assert_eq!(first.processes[0].name, "sh.exe"); assert_eq!(second.process_group_id, 50); assert_eq!(indices, &[3, 4, 5, 6]); assert!(std::ptr::eq(indices, snapshot.agent_indices.get().unwrap())); assert_eq!(inspected, vec![10, 20, 30, 40, 50]); } #[test] fn windows_process_tree_skips_runtime_inspection_for_non_git_bash_shell() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 99, "codex.exe", &["codex.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| false, |_| panic!("runtime marker must not be read for non-Git-Bash panes"), ) .unwrap(); assert_eq!(job.process_group_id, 10); } #[test] fn windows_process_tree_skips_runtime_inspection_without_agent_candidate() { let entries = vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry(20, 99, "git.exe", &["git.exe", "status"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |_| panic!("runtime marker must not be read without an agent candidate"), ) .unwrap(); assert_eq!(job.process_group_id, 10); } #[test] fn windows_process_tree_rejects_missing_or_empty_shell_runtime_marker() { let entries = vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry(20, 99, "codex.exe", &["codex.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); for shell_marker in [None, Some(String::new())] { let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |entry| { if entry.pid == 10 { shell_marker.clone() } else { Some("pane-a".to_string()) } }, ) .unwrap(); assert_eq!(job.process_group_id, 10); } } #[test] fn windows_process_tree_rejects_runtime_marker_from_another_pane() { let entries = vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry(20, 99, "codex.exe", &["codex.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |entry| Some(if entry.pid == 10 { "pane-a" } else { "pane-b" }.to_string()), ) .unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "bash.exe"); } #[test] fn windows_process_tree_rejects_ambiguous_runtime_marker_candidates() { let entries = vec![ test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), test_entry(20, 99, "codex.exe", &["codex.exe"]), test_entry(30, 98, "claude.exe", &["claude.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( 10, &snapshot, |_| true, |_| Some("pane-a".to_string()), ) .unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "bash.exe"); } #[test] fn windows_foreground_process_snapshot_is_shared_within_ttl() { let mut cache = super::ProcessSnapshotCache { cached: None }; let mut builds = 0; let mut first_build_completed_at = None; let first = cache.snapshot(Duration::from_secs(60), || { builds += 1; let entries = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])]; first_build_completed_at = Some(Instant::now()); entries }); assert!(cache.cached.as_ref().unwrap().built_at >= first_build_completed_at.unwrap()); let second = cache.snapshot(Duration::from_secs(60), || { builds += 1; Vec::new() }); let refreshed = cache.snapshot(Duration::ZERO, || { builds += 1; vec![test_entry(20, 1, "pwsh.exe", &["pwsh.exe"])] }); assert!(Arc::ptr_eq(&first, &second)); assert!(!Arc::ptr_eq(&second, &refreshed)); assert_eq!(builds, 2); assert_eq!(refreshed.entries[0].pid, 20); } #[test] fn windows_foreground_selection_cache_reuses_live_agent_and_invalidates_changes() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), ]); let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); let mut cache = super::ForegroundSelectionCache::default(); cache.remember_for_test(10, &snapshot, &job); assert_eq!(cache.get(10, &snapshot), Some(job.clone())); cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub { running: false, creation_time: None, }; assert_eq!(cache.get(10, &snapshot), None); cache.remember_for_test(10, &snapshot, &job); let overlap = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "claude.exe", &["claude.exe"]), ]); assert_eq!(cache.get(10, &overlap), None); cache.remember_for_test(10, &snapshot, &job); let changed = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "git.exe", &["git.exe"]), ]); assert_eq!(cache.get(10, &changed), None); cache.remember_for_test(10, &snapshot, &job); cache.entries.get_mut(&10).unwrap().verified_at = Instant::now() .checked_sub(super::FOREGROUND_SELECTION_RECHECK + Duration::from_secs(1)) .unwrap(); assert_eq!(cache.get(10, &snapshot), None); } #[test] fn windows_foreground_selection_cache_rejects_reused_descendant_pid() { let original = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "node.exe", &["node.exe", "worker.js"]), ]); let job = super::foreground_job_from_entry(original.entry(20).unwrap()); let mut cache = super::ForegroundSelectionCache::default(); cache.remember_for_test(10, &original, &job); let cached = cache.entries.get_mut(&10).unwrap(); let reused_index = cached .descendants .iter() .position(|entry| entry.pid == 30) .unwrap(); cached.descendant_identities[reused_index] = super::ProcessIdentity::Stub { running: false, creation_time: None, }; let replacement = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "node.exe", &["node.exe", "codex.js"]), ]); assert_eq!(cache.get(10, &replacement), None); } #[test] fn windows_foreground_selection_cache_rejects_identity_change_before_insertion() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry_with_creation_time(10, 1, "powershell.exe", &["powershell.exe"], Some(1)), test_entry_with_creation_time(20, 10, "codex.exe", &["codex.exe"], Some(2)), test_entry_with_creation_time(30, 10, "node.exe", &["node.exe", "worker.js"], Some(3)), ]); let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); let descendants = snapshot.descendant_signatures(10); let descendant_identities = vec![ super::ProcessIdentity::Stub { running: true, creation_time: Some(2), }, super::ProcessIdentity::Stub { running: true, creation_time: Some(4), }, ]; let mut cache = super::ForegroundSelectionCache::default(); let cached = super::CachedForegroundSelection::from_snapshot_with_identities( 10, &snapshot, &job, descendants, descendant_identities, super::ProcessIdentity::Stub { running: true, creation_time: Some(1), }, super::ProcessIdentity::Stub { running: true, creation_time: Some(2), }, ); cache.remember(10, cached); assert!(cache.entries.is_empty()); } #[test] fn windows_foreground_selection_cache_accepts_limited_information_identity() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry_without_cmdline(10, 1, "powershell.exe", 1), test_entry_without_cmdline(20, 10, "codex.exe", 2), ]); let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); let cached = super::CachedForegroundSelection::from_snapshot_with_identities( 10, &snapshot, &job, snapshot.descendant_signatures(10), vec![super::ProcessIdentity::Stub { running: true, creation_time: Some(2), }], super::ProcessIdentity::Stub { running: true, creation_time: Some(1), }, super::ProcessIdentity::Stub { running: true, creation_time: Some(2), }, ); assert!(cached.is_some()); assert_eq!(job.processes[0].argv0.as_deref(), Some("codex.exe")); assert!(job.processes[0].cmdline.is_none()); } #[test] fn windows_foreground_selection_cache_prunes_unused_entries_on_insertion() { let first_snapshot = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), ]); let first_job = super::foreground_job_from_entry(first_snapshot.entry(20).unwrap()); let mut cache = super::ForegroundSelectionCache::default(); cache.remember_for_test(10, &first_snapshot, &first_job); cache.entries.get_mut(&10).unwrap().last_used = Instant::now() .checked_sub(super::FOREGROUND_SELECTION_CACHE_RETENTION + Duration::from_secs(1)) .unwrap(); let second_snapshot = super::ProcessSnapshot::new(vec![ test_entry(11, 1, "powershell.exe", &["powershell.exe"]), test_entry(21, 11, "claude.exe", &["claude.exe"]), ]); let second_job = super::foreground_job_from_entry(second_snapshot.entry(21).unwrap()); cache.remember_for_test(11, &second_snapshot, &second_job); assert!(!cache.entries.contains_key(&10)); assert!(cache.entries.contains_key(&11)); } #[test] fn windows_foreground_selection_cache_does_not_retain_shell_result() { let snapshot = super::ProcessSnapshot::new(vec![test_entry( 10, 1, "powershell.exe", &["powershell.exe"], )]); let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap()); let mut cache = super::ForegroundSelectionCache::default(); cache.remember_for_test(10, &snapshot, &shell); assert!(cache.entries.is_empty()); } #[test] fn windows_foreground_selection_cache_retains_live_escaped_agent() { let snapshot = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "bash.exe", &["bash.exe"]), test_entry(20, 99, "launcher.exe", &["codex.exe"]), ]); let escaped = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); let mut cache = super::ForegroundSelectionCache::default(); cache.remember_for_test(10, &snapshot, &escaped); assert_eq!(cache.get(10, &snapshot), Some(escaped.clone())); cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub { running: false, creation_time: None, }; assert_eq!(cache.get(10, &snapshot), None); } #[test] fn windows_process_tree_selects_wrapped_agent_descendant() { let entries = vec![ test_entry(10, 1, "cmd.exe", &["cmd.exe"]), test_entry( 20, 10, "node.exe", &[ "node.exe", "C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\codex\\bin\\codex.js", ], ), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "node.exe"); } #[test] fn windows_process_tree_selects_cmd_wrapped_agent_descendant() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry( 20, 10, "cmd.exe", &[ "cmd.exe", "/D", "/S", "/C", "C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5", ], ), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "cmd.exe"); } #[test] fn windows_process_tree_selects_topmost_codex_process_in_single_agent_chain() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry( 20, 10, "node.exe", &[ "node.exe", "C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\bin\\codex.js", ], ), test_entry( 30, 20, "codex.exe", &["C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\@openai\\codex\\node_modules\\@openai\\codex-win32-x64\\vendor\\x86_64-pc-windows-msvc\\bin\\codex.exe"], ), test_entry(40, 30, "node_repl.exe", &["node_repl.exe"]), test_entry( 50, 40, "codex.exe", &["codex.exe", "app-server", "--listen", "stdio://"], ), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "node.exe"); } #[test] fn windows_process_tree_keeps_topmost_agent_over_different_agent_descendant() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "claude.exe", &["claude.exe"]), test_entry( 30, 20, "cmd.exe", &["cmd.exe", "/D", "/S", "/C", "codex mcp-server"], ), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 20); assert_eq!(job.processes[0].name, "claude.exe"); } #[test] fn windows_process_tree_keeps_root_agent_over_agent_descendant() { let entries = vec![ test_entry(10, 1, "claude.exe", &["claude.exe"]), test_entry( 20, 10, "cmd.exe", &["cmd.exe", "/D", "/S", "/C", "codex mcp-server"], ), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "claude.exe"); } #[test] fn windows_process_tree_returns_shell_for_same_agent_siblings() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "codex.exe", &["codex.exe"]), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "powershell.exe"); } #[test] fn windows_process_tree_returns_shell_for_plain_descendant() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "git.exe", &["git.exe", "status"]), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "powershell.exe"); } #[test] fn windows_shell_is_available_only_without_descendants() { let shell_only = super::ProcessSnapshot::new(vec![test_entry( 10, 1, "powershell.exe", &["powershell.exe"], )]); assert_eq!( super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(), Some("powershell.exe") ); let busy = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "git.exe", &["git.exe", "status"]), ]); assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None); let replaced = super::ProcessSnapshot::new(vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]); assert_eq!( super::available_pane_shell_from_snapshot(10, &replaced), None ); } #[test] fn windows_process_tree_returns_shell_for_multiple_agent_descendants() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "claude.exe", &["claude.exe"]), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "powershell.exe"); } #[test] fn windows_session_processes_collects_shell_and_descendants() { let entries = vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "cmd.exe", &["cmd.exe"]), test_entry(30, 20, "node.exe", &["node.exe"]), test_entry(40, 1, "unrelated.exe", &["unrelated.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let mut pids = super::session_processes_from_snapshot(10, &snapshot); pids.sort_unstable(); assert_eq!(pids, vec![10, 20, 30]); } #[test] fn windows_process_tree_ignores_pid_reuse_cycles() { let entries = vec![ test_entry(10, 30, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 20, "node.exe", &["node.exe"]), ]; let snapshot = super::ProcessSnapshot::new(entries); let descendants = super::descendant_entries(10, &snapshot); assert_eq!( descendants .iter() .map(|entry| entry.pid) .collect::>(), vec![20, 30] ); } #[test] fn windows_process_tree_returns_shell_when_candidate_parent_chain_cycles() { let entries = vec![ test_entry(10, 40, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "codex.exe", &["codex.exe"]), test_entry(30, 10, "codex.exe", &["codex.exe"]), test_entry(40, 10, "node.exe", &["node.exe"]), ]; let job = super::select_pane_foreground_job(10, &entries).unwrap(); assert_eq!(job.process_group_id, 10); assert_eq!(job.processes[0].name, "powershell.exe"); } #[test] fn scrollback_editor_argv_uses_editor_env_and_appends_path() { let path = std::path::Path::new(r"C:\Users\User\AppData\Local\Temp\herdr scrollback.txt"); let argv = super::scrollback_editor_argv_with_env( path, Some(r#""C:\Program Files\Microsoft VS Code\Code.exe" --wait"#), ) .unwrap(); assert_eq!(argv[0], r"C:\Program Files\Microsoft VS Code\Code.exe"); assert_eq!(argv[1], "--wait"); assert_eq!(argv[2], path.display().to_string()); } #[test] fn scrollback_editor_argv_falls_back_to_notepad() { let path = std::path::Path::new(r"C:\Temp\herdr-scrollback.txt"); let argv = super::scrollback_editor_argv_with_env(path, None).unwrap(); assert_eq!( argv, vec!["notepad.exe".to_string(), path.display().to_string()] ); } fn test_entry( pid: u32, parent_pid: u32, name: &str, argv: &[&str], ) -> super::WindowsProcessEntry { test_entry_with_creation_time(pid, parent_pid, name, argv, None) } fn test_entry_without_cmdline( pid: u32, parent_pid: u32, name: &str, creation_time: u64, ) -> super::WindowsProcessEntry { let command = super::OnceLock::new(); command .set(super::WindowsProcessCommand::from_cmdline( name, Some(creation_time), None, )) .unwrap(); super::WindowsProcessEntry { pid, parent_pid, name: name.to_string(), command, } } fn test_entry_with_creation_time( pid: u32, parent_pid: u32, name: &str, argv: &[&str], creation_time: Option, ) -> super::WindowsProcessEntry { let command = super::OnceLock::new(); command .set(super::WindowsProcessCommand { creation_time, argv0: argv.first().map(|value| (*value).to_string()), argv: Some(argv.iter().map(|value| (*value).to_string()).collect()), cmdline: Some(argv.join(" ")), }) .unwrap(); super::WindowsProcessEntry { pid, parent_pid, name: name.to_string(), command, } } #[test] fn process_environment_variable_parser_reads_case_insensitive_marker() { let environment: Vec = "PATH=C:\\Windows\0herdr_pane_runtime_id=pane-a\0\0" .encode_utf16() .collect(); assert_eq!( super::environment_variable_from_utf16( &environment, super::PANE_RUNTIME_MARKER_ENV_VAR, ) .as_deref(), Some("pane-a") ); } #[test] fn pane_runtime_markers_are_distinct() { let first = super::next_pane_runtime_marker(); let second = super::next_pane_runtime_marker(); assert_ne!(first, second); } #[test] fn pane_runtime_marker_is_added_only_to_git_bash_environment() { let root = std::env::temp_dir().join(format!( "herdr-git-bash-test-{}", super::next_pane_runtime_marker() )); fs::create_dir_all(root.join("bin")).expect("create Git Bash bin fixture"); fs::create_dir_all(root.join("usr").join("bin")).expect("create Git Bash usr/bin fixture"); fs::create_dir_all(root.join("cmd")).expect("create Git Bash cmd fixture"); fs::write(root.join("bin").join("bash.exe"), []).expect("create Bash fixture"); fs::write(root.join("usr").join("bin").join("msys-2.0.dll"), []) .expect("create MSYS runtime fixture"); fs::write(root.join("cmd").join("git.exe"), []).expect("create Git fixture"); let mut git_bash = portable_pty::CommandBuilder::new(root.join("bin").join("bash.exe")); super::apply_pane_runtime_marker_platform(&mut git_bash); let mut path_resolved_git_bash = portable_pty::CommandBuilder::new("bash.exe"); path_resolved_git_bash.env("PATH", root.join("bin")); super::apply_pane_runtime_marker_platform(&mut path_resolved_git_bash); let mut cmd = portable_pty::CommandBuilder::new("cmd.exe"); super::apply_pane_runtime_marker_platform(&mut cmd); assert!(git_bash .get_env(super::PANE_RUNTIME_MARKER_ENV_VAR) .is_some_and(|value| !value.is_empty())); assert!(path_resolved_git_bash .get_env(super::PANE_RUNTIME_MARKER_ENV_VAR) .is_some_and(|value| !value.is_empty())); assert!(cmd.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR).is_none()); fs::remove_dir_all(root).expect("remove Git Bash fixture"); } #[test] fn ime_open_reflects_open_status() { // IMC_GETOPENSTATUS returns nonzero when the IME is open (Hangul // composing) and zero for direct English/ASCII input. assert!(super::ime_open(1)); assert!(!super::ime_open(0)); // Any nonzero value is treated as open, not just 1. assert!(super::ime_open(2)); } #[test] fn toggle_key_maps_korean_and_ignores_other_languages() { // Korean (0x0412) -> Hangul/English toggle. assert_eq!( super::toggle_key_for_language(0x0412), Some(super::VK_HANGUL) ); // Korean with a different sublanguage still resolves by primary id. assert_eq!( super::toggle_key_for_language(0x0812), Some(super::VK_HANGUL) ); // Japanese (0x0411) and Chinese (0x0804) have no mapped key yet. assert_eq!(super::toggle_key_for_language(0x0411), None); assert_eq!(super::toggle_key_for_language(0x0804), None); // English (0x0409): nothing to toggle. assert_eq!(super::toggle_key_for_language(0x0409), None); } #[test] fn send_vk_tap_reports_success_when_full_tap_is_queued() { let mut calls = 0; let ok = super::send_vk_tap_with(super::VK_HANGUL, |events| { calls += 1; events.len() as u32 }); assert!(ok, "a fully queued tap is reported as success"); assert_eq!(calls, 1, "a clean tap needs no retry"); } #[test] fn send_vk_tap_retries_keyup_and_reports_toggle_on_partial_injection() { let mut calls = 0; let mut retry_len = 0; let mut retry_is_keyup = false; let ok = super::send_vk_tap_with(super::VK_HANGUL, |events| { calls += 1; if calls == 1 { // Only the key-down is queued; the key-up is dropped. 1 } else { retry_len = events.len(); // SAFETY: keyboard inputs, so reading the `ki` union is valid. retry_is_keyup = unsafe { events[0].Anonymous.ki.dwFlags } == super::KEYEVENTF_KEYUP; events.len() as u32 } }); assert!(ok, "the queued key-down may have toggled the IME"); assert_eq!(calls, 2, "the dropped key-up is retried exactly once"); assert_eq!(retry_len, 1, "only the key-up is retried"); assert!(retry_is_keyup, "the retry injects the key-up event"); } #[test] fn send_vk_tap_reports_toggle_when_keyup_retry_fails() { let mut calls = 0; let ok = super::send_vk_tap_with(super::VK_HANGUL, |_events| { calls += 1; if calls == 1 { 1 } else { 0 } }); assert!(ok, "the queued key-down may have toggled the IME"); assert_eq!(calls, 2, "the dropped key-up is retried exactly once"); } #[test] fn send_vk_tap_reports_failure_without_retry_when_nothing_is_queued() { let mut calls = 0; let ok = super::send_vk_tap_with(super::VK_HANGUL, |_events| { calls += 1; 0 }); assert!(!ok, "a fully blocked tap is reported as failure"); assert_eq!( calls, 1, "nothing was queued, so there is no key-up to retry" ); } #[test] fn key_tap_inputs_emit_keydown_then_keyup() { let inputs = super::key_tap_inputs(super::VK_HANGUL); // SAFETY: both entries are keyboard inputs, so reading the `ki` union is valid. unsafe { assert_eq!(inputs[0].r#type, super::INPUT_KEYBOARD); assert_eq!(inputs[0].Anonymous.ki.wVk, super::VK_HANGUL); assert_eq!(inputs[0].Anonymous.ki.dwFlags, 0, "first event is key-down"); assert_eq!(inputs[1].r#type, super::INPUT_KEYBOARD); assert_eq!(inputs[1].Anonymous.ki.wVk, super::VK_HANGUL); assert_eq!( inputs[1].Anonymous.ki.dwFlags, super::KEYEVENTF_KEYUP, "second event is key-up" ); } } }