aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/platform
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/fallback.rs2
-rw-r--r--src/platform/mod.rs31
-rw-r--r--src/platform/windows.rs590
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(" ")),
+ }
+ }
+}