diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 00:07:24 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-07 16:29:44 +0300 |
| commit | 3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch) | |
| tree | a52280bccc824ca656f8bc986a756f30901974e4 /src/platform | |
| parent | 5d7e56737ef30d9de5802b0684a9428c40131dda (diff) | |
feat: add native windows beta support
Diffstat (limited to 'src/platform')
| -rw-r--r-- | src/platform/fallback.rs | 2 | ||||
| -rw-r--r-- | src/platform/mod.rs | 31 | ||||
| -rw-r--r-- | src/platform/windows.rs | 590 |
3 files changed, 620 insertions, 3 deletions
diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index 42abfcb2..0f48d32d 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -52,6 +52,8 @@ pub fn open_url(_url: &str) -> std::io::Result<()> { } /// Unsupported platform stub. +// Windows does not wire clipboard-image bridging into semantic input yet. +#[cfg_attr(windows, allow(dead_code))] pub fn read_clipboard_image() -> Option<ClipboardImage> { None } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 12c275f0..ec9a497f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -25,6 +25,22 @@ pub enum Signal { Kill, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PlatformCapabilities { + pub(crate) live_handoff: bool, + pub(crate) remote_attach: bool, + pub(crate) direct_terminal_attach: bool, +} + +pub(crate) const fn capabilities() -> PlatformCapabilities { + PlatformCapabilities { + live_handoff: cfg!(unix), + remote_attach: cfg!(unix), + direct_terminal_attach: cfg!(unix), + } +} + +#[cfg(unix)] #[derive(Debug, Clone, PartialEq, Eq)] pub struct ClipboardCommand { pub program: &'static str, @@ -32,11 +48,14 @@ pub struct ClipboardCommand { } #[derive(Debug, Clone, PartialEq, Eq)] +// Windows does not wire clipboard-image bridging into semantic input yet. +#[cfg_attr(windows, allow(dead_code))] pub struct ClipboardImage { pub bytes: Vec<u8>, pub extension: &'static str, } +#[cfg(unix)] #[derive(Debug, PartialEq, Eq)] pub(crate) enum LimitedRead { Empty, @@ -44,6 +63,7 @@ pub(crate) enum LimitedRead { Oversized, } +#[cfg(unix)] pub(crate) fn read_limited_reader( mut reader: impl std::io::Read, max_bytes: usize, @@ -91,9 +111,14 @@ mod macos; #[cfg(target_os = "macos")] pub use macos::*; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "windows")] +pub use windows::*; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] mod fallback; -#[cfg(not(any(target_os = "linux", target_os = "macos")))] +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] pub use fallback::*; #[cfg(not(target_os = "macos"))] @@ -139,7 +164,7 @@ impl PrefixInputSource for RealPrefixInputSource { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/src/platform/windows.rs b/src/platform/windows.rs new file mode 100644 index 00000000..a8ad2082 --- /dev/null +++ b/src/platform/windows.rs @@ -0,0 +1,590 @@ +use std::{ + collections::{HashMap, VecDeque}, + ffi::c_void, + mem::{size_of, MaybeUninit}, + path::PathBuf, + ptr::null_mut, +}; + +use windows_sys::{ + Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation}, + Win32::{ + Foundation::{ + CloseHandle, LocalFree, HANDLE, INVALID_HANDLE_VALUE, NTSTATUS, STATUS_SUCCESS, + UNICODE_STRING, + }, + System::{ + Diagnostics::{ + Debug::ReadProcessMemory, + ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }, + }, + Threading::{ + GetExitCodeProcess, OpenProcess, TerminateProcess, PROCESS_BASIC_INFORMATION, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ, + }, + }, + UI::Shell::{CommandLineToArgvW, ShellExecuteW}, + }, +}; + +use super::{ClipboardImage, ForegroundJob, Signal}; + +const STILL_ACTIVE: u32 = 259; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct WindowsProcessEntry { + pid: u32, + parent_pid: u32, + name: String, + argv0: Option<String>, + argv: Option<Vec<String>>, + cmdline: Option<String>, +} + +pub fn raise_server_nofile_limit() {} + +pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { + let entries = snapshot_processes(); + select_pane_foreground_job(child_pid, &entries) +} + +pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> { + let entries = snapshot_processes(); + let entry = entries.iter().find(|entry| entry.pid == 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<u32> { + foreground_job(child_pid).map(|job| job.process_group_id) +} + +pub fn process_cwd(pid: u32) -> Option<PathBuf> { + 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( + shell_pid: u32, + entries: &[WindowsProcessEntry], +) -> Option<ForegroundJob> { + let shell = entries.iter().find(|entry| entry.pid == shell_pid)?; + let shell_job = || ForegroundJob { + process_group_id: shell_pid, + processes: vec![foreground_process_from_entry(shell)], + }; + + let descendants = descendant_entries(shell_pid, entries); + let mut candidates = Vec::new(); + for entry in descendants { + let process = foreground_process_from_entry(entry); + let job = ForegroundJob { + process_group_id: entry.pid, + processes: vec![process], + }; + if crate::detect::identify_agent_in_job(&job).is_some() { + candidates.push(job); + } + } + + match candidates.len() { + 1 => candidates.pop(), + _ => Some(shell_job()), + } +} + +fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&WindowsProcessEntry> { + let mut children: HashMap<u32, Vec<&WindowsProcessEntry>> = HashMap::new(); + for entry in entries { + children.entry(entry.parent_pid).or_default().push(entry); + } + + let mut output = Vec::new(); + let mut queue = VecDeque::new(); + if let Some(root_children) = children.get(&root_pid) { + queue.extend(root_children.iter().copied()); + } + while let Some(entry) = queue.pop_front() { + output.push(entry); + if let Some(next) = children.get(&entry.pid) { + queue.extend(next.iter().copied()); + } + } + output +} + +fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess { + super::ForegroundProcess { + pid: entry.pid, + name: entry.name.clone(), + argv0: entry.argv0.clone(), + argv: entry.argv.clone(), + cmdline: entry.cmdline.clone(), + } +} + +fn snapshot_processes() -> Vec<WindowsProcessEntry> { + 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::<PROCESSENTRY32W>() 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); + let cmdline = process_command_line(pid); + 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.clone())); + output.push(WindowsProcessEntry { + pid, + parent_pid: entry.th32ParentProcessID, + name, + argv0, + argv, + cmdline, + }); + ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0; + } + output +} + +fn process_command_line(pid: u32) -> Option<String> { + let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?; + let parameters = read_process_parameters(process.0)?; + read_unicode_string(process.0, parameters.command_line) +} + +fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> { + let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit(); + let status = unsafe { + NtQueryInformationProcess( + process, + ProcessBasicInformation, + basic_info.as_mut_ptr().cast::<c_void>(), + size_of::<PROCESS_BASIC_INFORMATION>() 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::<Peb>(process, basic_info.PebBaseAddress.cast::<c_void>())?; + if peb.process_parameters.is_null() { + return None; + } + + read_process_value::<RtlUserProcessParameters>(process, peb.process_parameters.cast()) +} + +fn command_line_to_argv(command_line: &str) -> Option<Vec<String>> { + let wide: Vec<u16> = 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<u32> { + if child_pid == 0 { + return Vec::new(); + } + + let entries = snapshot_processes(); + session_processes_from_entries(child_pid, &entries) +} + +fn session_processes_from_entries(child_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<u32> { + if !entries.iter().any(|entry| entry.pid == child_pid) { + return Vec::new(); + } + + let mut pids = vec![child_pid]; + pids.extend( + descendant_entries(child_pid, &entries) + .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 { + false +} + +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(()) + } else { + Err(std::io::Error::other(format!( + "failed to open URL with ShellExecuteW: code {}", + result as isize + ))) + } +} + +// Windows does not wire clipboard-image bridging into semantic input yet. +#[cfg_attr(windows, allow(dead_code))] +pub fn read_clipboard_image() -> Option<ClipboardImage> { + None +} + +pub fn show_desktop_notification(_title: &str, _body: Option<&str>) -> std::io::Result<bool> { + Ok(false) +} + +fn wide_null(value: &str) -> Vec<u16> { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +struct ProcessHandle(HANDLE); + +impl ProcessHandle { + fn open(pid: u32, access: u32) -> Option<Self> { + 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, +} + +fn read_process_value<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> { + if address.is_null() { + return None; + } + + let mut value = MaybeUninit::<T>::uninit(); + let mut bytes_read = 0; + let ok = unsafe { + ReadProcessMemory( + process, + address, + value.as_mut_ptr().cast::<c_void>(), + size_of::<T>(), + &mut bytes_read, + ) + } != 0; + + (ok && bytes_read == size_of::<T>()).then(|| unsafe { value.assume_init() }) +} + +fn read_unicode_string(process: HANDLE, unicode: UNICODE_STRING) -> Option<String> { + if unicode.Buffer.is_null() || unicode.Length == 0 || unicode.Length % 2 != 0 { + 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::<c_void>(), + buffer.as_mut_ptr().cast::<c_void>(), + usize::from(unicode.Length), + &mut bytes_read, + ) + } != 0; + + if !ok || bytes_read != usize::from(unicode.Length) { + return None; + } + + String::from_utf16(&buffer).ok() +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, + }; + + #[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 mut child = Command::new("powershell.exe") + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 10"]) + .current_dir(&cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn powershell"); + + 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_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 job = super::select_pane_foreground_job(10, &entries).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_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_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_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 mut pids = super::session_processes_from_entries(10, &entries); + pids.sort_unstable(); + + assert_eq!(pids, vec![10, 20, 30]); + } + + fn test_entry( + pid: u32, + parent_pid: u32, + name: &str, + argv: &[&str], + ) -> super::WindowsProcessEntry { + super::WindowsProcessEntry { + pid, + parent_pid, + name: name.to_string(), + argv0: argv.first().map(|value| (*value).to_string()), + argv: Some(argv.iter().map(|value| (*value).to_string()).collect()), + cmdline: Some(argv.join(" ")), + } + } +} |
