mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
517 lines
16 KiB
Rust
517 lines
16 KiB
Rust
//! Platform-specific process and filesystem operations.
|
|
//!
|
|
//! Centralizes OS-dependent behavior behind a clean boundary so core
|
|
//! modules don't scatter `#[cfg]` branches through product logic.
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ForegroundProcess {
|
|
pub pid: u32,
|
|
pub name: String,
|
|
pub argv0: Option<String>,
|
|
pub argv: Option<Vec<String>>,
|
|
pub cmdline: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ForegroundJob {
|
|
pub process_group_id: u32,
|
|
pub processes: Vec<ForegroundProcess>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Signal {
|
|
Hangup,
|
|
Terminate,
|
|
Kill,
|
|
}
|
|
|
|
pub(crate) fn detached_custom_command_process(command: &str) -> std::process::Command {
|
|
let mut process = detached_custom_command_process_platform(command);
|
|
configure_background_command(&mut process);
|
|
process
|
|
}
|
|
|
|
pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::CommandBuilder {
|
|
pane_custom_command_pty_builder_platform(command)
|
|
}
|
|
|
|
pub(crate) fn apply_pane_runtime_marker(command: &mut portable_pty::CommandBuilder) {
|
|
apply_pane_runtime_marker_platform(command);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn apply_pane_runtime_marker_platform(_command: &mut portable_pty::CommandBuilder) {}
|
|
|
|
pub(crate) fn configure_background_command(command: &mut std::process::Command) {
|
|
configure_background_command_platform(command);
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn configure_background_command_platform(_command: &mut std::process::Command) {}
|
|
|
|
#[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) preserve_legacy_doubled_escape_input: bool,
|
|
}
|
|
|
|
pub(crate) const fn capabilities() -> PlatformCapabilities {
|
|
PlatformCapabilities {
|
|
live_handoff: cfg!(unix),
|
|
remote_attach: cfg!(unix),
|
|
direct_terminal_attach: cfg!(unix),
|
|
preserve_legacy_doubled_escape_input: cfg!(target_os = "macos"),
|
|
}
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn launch_server_daemon_command(command: &mut std::process::Command) -> std::io::Result<u32> {
|
|
command.spawn().map(|child| child.id())
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub fn detach_server_daemon_command(command: &mut std::process::Command) {
|
|
use std::os::unix::process::CommandExt;
|
|
|
|
unsafe {
|
|
command.pre_exec(|| {
|
|
if libc::setsid() < 0 {
|
|
return Err(std::io::Error::last_os_error());
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub fn current_process_is_detached_server_daemon() -> bool {
|
|
unsafe { libc::getsid(0) == libc::getpid() }
|
|
}
|
|
|
|
/// Raised by the SIGWINCH handler, consumed by the host resize watcher.
|
|
#[cfg(unix)]
|
|
static TERMINAL_RESIZE_SIGNALLED: std::sync::atomic::AtomicBool =
|
|
std::sync::atomic::AtomicBool::new(false);
|
|
|
|
#[cfg(unix)]
|
|
extern "C" fn record_terminal_resize_signal(_signal: libc::c_int) {
|
|
TERMINAL_RESIZE_SIGNALLED.store(true, std::sync::atomic::Ordering::Release);
|
|
}
|
|
|
|
/// Records SIGWINCH events that size polling can miss.
|
|
#[cfg(unix)]
|
|
pub(crate) fn watch_terminal_resize_signal() {
|
|
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
|
|
action.sa_sigaction =
|
|
record_terminal_resize_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
|
|
// Keep blocking stdin and socket reads from failing with EINTR.
|
|
action.sa_flags = libc::SA_RESTART;
|
|
unsafe {
|
|
libc::sigemptyset(&mut action.sa_mask);
|
|
libc::sigaction(libc::SIGWINCH, &action, std::ptr::null_mut());
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
pub(crate) fn watch_terminal_resize_signal() {}
|
|
|
|
/// Returns whether a terminal size change was signalled since the last call.
|
|
#[cfg(unix)]
|
|
pub(crate) fn take_terminal_resize_signal() -> bool {
|
|
TERMINAL_RESIZE_SIGNALLED.swap(false, std::sync::atomic::Ordering::AcqRel)
|
|
}
|
|
|
|
/// Windows relies on size polling.
|
|
#[cfg(not(unix))]
|
|
pub(crate) fn take_terminal_resize_signal() -> bool {
|
|
false
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ClipboardCommand {
|
|
pub program: &'static str,
|
|
pub args: &'static [&'static str],
|
|
}
|
|
|
|
#[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,
|
|
Complete(Vec<u8>),
|
|
Oversized,
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
pub(crate) fn read_limited_reader(
|
|
mut reader: impl std::io::Read,
|
|
max_bytes: usize,
|
|
) -> std::io::Result<LimitedRead> {
|
|
let mut bytes = Vec::new();
|
|
let mut buffer = [0_u8; 8192];
|
|
|
|
while bytes.len() < max_bytes {
|
|
let remaining = max_bytes - bytes.len();
|
|
let read_len = remaining.min(buffer.len());
|
|
let bytes_read = match reader.read(&mut buffer[..read_len]) {
|
|
Ok(bytes_read) => bytes_read,
|
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
Err(err) => return Err(err),
|
|
};
|
|
if bytes_read == 0 {
|
|
return if bytes.is_empty() {
|
|
Ok(LimitedRead::Empty)
|
|
} else {
|
|
Ok(LimitedRead::Complete(bytes))
|
|
};
|
|
}
|
|
bytes.extend_from_slice(&buffer[..bytes_read]);
|
|
}
|
|
|
|
let mut sentinel = [0_u8; 1];
|
|
loop {
|
|
return match reader.read(&mut sentinel) {
|
|
Ok(0) if bytes.is_empty() => Ok(LimitedRead::Empty),
|
|
Ok(0) => Ok(LimitedRead::Complete(bytes)),
|
|
Ok(_) => Ok(LimitedRead::Oversized),
|
|
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
|
|
Err(err) => Err(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod linux;
|
|
#[cfg(target_os = "linux")]
|
|
pub use linux::*;
|
|
|
|
#[cfg(target_os = "macos")]
|
|
mod macos;
|
|
#[cfg(target_os = "macos")]
|
|
pub use 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", target_os = "windows")))]
|
|
pub use fallback::*;
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub(crate) fn available_pane_shell_from_job(child_pid: u32, job: ForegroundJob) -> Option<String> {
|
|
if job.process_group_id != child_pid
|
|
|| job.processes.iter().any(|process| process.pid != child_pid)
|
|
{
|
|
return None;
|
|
}
|
|
job.processes
|
|
.into_iter()
|
|
.find(|process| process.pid == child_pid)
|
|
.map(|process| process.name)
|
|
.filter(|name| is_pane_shell_process_name(name))
|
|
}
|
|
|
|
fn normalized_process_name(name: &str) -> String {
|
|
name.rsplit(['/', '\\'])
|
|
.next()
|
|
.unwrap_or(name)
|
|
.trim_start_matches('-')
|
|
.trim_end_matches(".exe")
|
|
.to_ascii_lowercase()
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub(crate) fn is_powershell_process_name(name: &str) -> bool {
|
|
matches!(
|
|
normalized_process_name(name).as_str(),
|
|
"pwsh" | "powershell"
|
|
)
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub(crate) fn interactive_unix_shell_command(
|
|
argv: &[String],
|
|
shell_name: &str,
|
|
quote_posix_arg: fn(&str) -> String,
|
|
) -> Option<String> {
|
|
let quote = if is_powershell_process_name(shell_name) {
|
|
quote_powershell_arg
|
|
} else {
|
|
quote_posix_arg
|
|
};
|
|
let mut parts = argv.iter();
|
|
let mut command = quote(parts.next()?);
|
|
for part in parts {
|
|
command.push(' ');
|
|
command.push_str("e(part));
|
|
}
|
|
Some(command)
|
|
}
|
|
|
|
pub(crate) fn quote_powershell_arg(value: &str) -> String {
|
|
if !value.is_empty()
|
|
&& value.bytes().all(|byte| {
|
|
byte.is_ascii_alphanumeric()
|
|
|| matches!(byte, b'_' | b'-' | b'.' | b'/' | b':' | b'+' | b'=')
|
|
})
|
|
{
|
|
return value.to_string();
|
|
}
|
|
format!("'{}'", value.replace('\'', "''"))
|
|
}
|
|
|
|
pub(crate) fn is_pane_shell_process_name(name: &str) -> bool {
|
|
let normalized = normalized_process_name(name);
|
|
matches!(
|
|
normalized.as_str(),
|
|
"sh" | "bash"
|
|
| "dash"
|
|
| "zsh"
|
|
| "fish"
|
|
| "ksh"
|
|
| "mksh"
|
|
| "csh"
|
|
| "tcsh"
|
|
| "elvish"
|
|
| "xonsh"
|
|
| "nu"
|
|
| "pwsh"
|
|
| "powershell"
|
|
| "cmd"
|
|
)
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
|
|
pub fn process_agent_hint(_pid: u32) -> Option<crate::detect::Agent> {
|
|
None
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
pub(crate) fn parse_agent_env_hint(environ: &[u8]) -> Option<crate::detect::Agent> {
|
|
for record in environ.split(|&byte| byte == 0) {
|
|
let Some(value) = record.strip_prefix(b"HERDR_AGENT=") else {
|
|
continue;
|
|
};
|
|
return crate::detect::parse_agent_label(std::str::from_utf8(value).ok()?);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
#[derive(Debug)]
|
|
pub(crate) struct InputSourceRestore;
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
pub(crate) fn switch_to_ascii_input_source() -> Option<InputSourceRestore> {
|
|
None
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
|
pub(crate) fn pump_input_source_runloop() {}
|
|
|
|
/// Switches the host keyboard input source while prefix mode is active.
|
|
///
|
|
/// `App` drives this through a trait so the prefix-mode transitions can be
|
|
/// tested with a fake, without touching the real macOS APIs or leaking a
|
|
/// platform-specific restore type into `App`.
|
|
pub(crate) trait PrefixInputSource {
|
|
/// Switch to an ASCII-capable input source for prefix commands. No-op if
|
|
/// the current source is already ASCII-capable, the platform is
|
|
/// unsupported, or the switch fails. Calling it again before `restore`
|
|
/// keeps the source saved by the first call.
|
|
fn switch_to_ascii(&mut self);
|
|
|
|
/// Restore whatever `switch_to_ascii` saved. No-op if nothing was switched.
|
|
fn restore(&mut self);
|
|
}
|
|
|
|
/// Production [`PrefixInputSource`] backed by the per-platform API.
|
|
#[derive(Default)]
|
|
pub(crate) struct RealPrefixInputSource {
|
|
restore: Option<InputSourceRestore>,
|
|
}
|
|
|
|
impl PrefixInputSource for RealPrefixInputSource {
|
|
fn switch_to_ascii(&mut self) {
|
|
if self.restore.is_none() {
|
|
// Drain pending input-source-change notifications so the read below is fresh (see
|
|
// `pump_input_source_runloop`); a no-op on non-macOS.
|
|
pump_input_source_runloop();
|
|
self.restore = switch_to_ascii_input_source();
|
|
}
|
|
}
|
|
|
|
fn restore(&mut self) {
|
|
let _ = self.restore.take();
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, unix))]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn terminal_resize_signal_is_recorded_once_per_delivery() {
|
|
watch_terminal_resize_signal();
|
|
assert!(!take_terminal_resize_signal());
|
|
|
|
unsafe {
|
|
libc::raise(libc::SIGWINCH);
|
|
}
|
|
|
|
assert!(take_terminal_resize_signal());
|
|
assert!(!take_terminal_resize_signal());
|
|
}
|
|
|
|
#[test]
|
|
fn pane_shell_process_names_reject_exec_replacement_programs() {
|
|
for shell in ["bash", "-zsh", "/bin/fish", "pwsh", "powershell.exe"] {
|
|
assert!(is_pane_shell_process_name(shell), "{shell}");
|
|
}
|
|
for program in ["vim", "nvim", "cargo", "test-runner", "opencode"] {
|
|
assert!(!is_pane_shell_process_name(program), "{program}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn detached_custom_command_preserves_unix_login_shell_flag() {
|
|
let cmd = detached_custom_command_process("echo hello");
|
|
assert_eq!(cmd.get_program(), std::ffi::OsStr::new("/bin/sh"));
|
|
assert_eq!(
|
|
cmd.get_args().collect::<Vec<_>>(),
|
|
[
|
|
std::ffi::OsStr::new("-lc"),
|
|
std::ffi::OsStr::new("echo hello")
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pane_custom_command_builder_preserves_unix_shell_flag() {
|
|
let expected: Vec<std::ffi::OsString> =
|
|
vec!["/bin/sh".into(), "-c".into(), "echo hello".into()];
|
|
assert_eq!(
|
|
pane_custom_command_pty_builder("echo hello").get_argv(),
|
|
&expected
|
|
);
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
#[test]
|
|
fn parse_agent_env_hint_accepts_known_agents() {
|
|
assert_eq!(
|
|
parse_agent_env_hint(b"PATH=/bin\0HERDR_AGENT=claude\0TERM=xterm\0"),
|
|
Some(crate::detect::Agent::Claude)
|
|
);
|
|
assert_eq!(
|
|
parse_agent_env_hint(b"HERDR_AGENT=codex"),
|
|
Some(crate::detect::Agent::Codex)
|
|
);
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
#[test]
|
|
fn parse_agent_env_hint_ignores_missing_or_unknown_agents() {
|
|
assert_eq!(parse_agent_env_hint(b"PATH=/bin\0TERM=xterm\0"), None);
|
|
assert_eq!(parse_agent_env_hint(b"HERDR_AGENT=not-an-agent\0"), None);
|
|
}
|
|
|
|
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
|
#[test]
|
|
fn interactive_shell_command_quotes_for_posix_and_powershell() {
|
|
let argv = vec![
|
|
"pi".into(),
|
|
String::new(),
|
|
"two words".into(),
|
|
"a'b".into(),
|
|
"$HOME".into(),
|
|
"semi;colon".into(),
|
|
"@options".into(),
|
|
];
|
|
assert_eq!(
|
|
interactive_shell_command(&argv, "bash").as_deref(),
|
|
Some("pi '' 'two words' 'a'\\''b' '$HOME' 'semi;colon' @options")
|
|
);
|
|
assert_eq!(
|
|
interactive_shell_command(&argv, "pwsh").as_deref(),
|
|
Some("pi '' 'two words' 'a''b' '$HOME' 'semi;colon' '@options'")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limited_reader_returns_complete_data_under_limit() {
|
|
let input = std::io::Cursor::new(b"image".to_vec());
|
|
assert_eq!(
|
|
read_limited_reader(input, 16).expect("limited read"),
|
|
LimitedRead::Complete(b"image".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limited_reader_returns_empty_for_empty_input() {
|
|
let input = std::io::Cursor::new(Vec::<u8>::new());
|
|
assert_eq!(
|
|
read_limited_reader(input, 16).expect("limited read"),
|
|
LimitedRead::Empty
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limited_reader_accepts_data_exactly_at_limit() {
|
|
let input = std::io::Cursor::new(b"four".to_vec());
|
|
assert_eq!(
|
|
read_limited_reader(input, 4).expect("limited read"),
|
|
LimitedRead::Complete(b"four".to_vec())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limited_reader_rejects_data_over_limit() {
|
|
let input = std::io::Cursor::new(b"oversized".to_vec());
|
|
assert_eq!(
|
|
read_limited_reader(input, 4).expect("limited read"),
|
|
LimitedRead::Oversized
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limited_reader_retries_interrupted_reads() {
|
|
struct InterruptedOnce {
|
|
interrupted: bool,
|
|
inner: std::io::Cursor<Vec<u8>>,
|
|
}
|
|
|
|
impl std::io::Read for InterruptedOnce {
|
|
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
|
if !self.interrupted {
|
|
self.interrupted = true;
|
|
return Err(std::io::ErrorKind::Interrupted.into());
|
|
}
|
|
self.inner.read(buffer)
|
|
}
|
|
}
|
|
|
|
let input = InterruptedOnce {
|
|
interrupted: false,
|
|
inner: std::io::Cursor::new(b"image".to_vec()),
|
|
};
|
|
assert_eq!(
|
|
read_limited_reader(input, 16).expect("limited read"),
|
|
LimitedRead::Complete(b"image".to_vec())
|
|
);
|
|
}
|
|
}
|