use std::io::{self, BufRead, Write as _}; use base64::Engine; use interprocess::local_socket::traits::Stream as _; use interprocess::TryClone as _; use tracing::info; use crate::ipc::LocalStream; use crate::protocol::{ self, AttachScrollDirection, AttachScrollSource, ClientMessage, RenderEncoding, ServerMessage, MAX_GRAPHICS_FRAME_SIZE, }; use crate::server::socket_paths::client_socket_path; use super::{do_handshake, init_logging, write_to_server, ClientError}; /// Runs a read-only terminal session observer and prints one JSON envelope per frame. pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io::Result<()> { let mut stream = connect_terminal_session_stream(target.clone(), cols, rows, "observing terminal session")?; write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; write_terminal_session_output(stream) } /// Runs a writable terminal session controller. pub fn run_terminal_session_control( target: String, takeover: bool, cols: u16, rows: u16, ) -> io::Result<()> { let mut stream = connect_terminal_session_stream( target.clone(), cols, rows, "controlling terminal session", )?; write_to_server( &mut stream, &ClientMessage::ControlTerminal { target, takeover }, )?; let mut write_stream = stream.try_clone()?; let _input_thread = std::thread::spawn(move || { let stdin = io::stdin(); for line in stdin.lock().lines() { let Ok(line) = line else { break; }; if line.trim().is_empty() { continue; } match terminal_control_command_from_json(&line) { Ok(message) => { let release = matches!(message, ClientMessage::Detach); if write_to_server(&mut write_stream, &message).is_err() { return; } if release { return; } } Err(err) => eprintln!("herdr: terminal session control input ignored: {err}"), } } let _ = write_to_server(&mut write_stream, &ClientMessage::Detach); }); write_terminal_session_output(stream) } fn connect_terminal_session_stream( target: String, cols: u16, rows: u16, log_message: &'static str, ) -> io::Result { init_logging(); let socket_path = client_socket_path(); crate::logging::startup("client"); info!(path = %socket_path.display(), target = %target, cols, rows, "{log_message}"); let mut stream = match crate::ipc::connect_local_stream(&socket_path) { Ok(stream) => stream, Err(err) => { eprintln!("herdr: {}", ClientError::ConnectionFailed(err)); std::process::exit(1); } }; match do_handshake( &mut stream, cols, rows, 0, 0, false, None, false, false, true, ) { Ok(handshake) if handshake.encoding == RenderEncoding::TerminalAnsi => {} Ok(handshake) => { eprintln!( "herdr: terminal session observe negotiated unsupported encoding {:?}", handshake.encoding ); std::process::exit(1); } Err(err) => { eprintln!("herdr: {err}"); std::process::exit(1); } } stream.set_nonblocking(false)?; Ok(stream) } fn write_terminal_session_output(mut stream: LocalStream) -> io::Result<()> { let mut stdout = io::stdout().lock(); loop { match protocol::read_message(&mut stream, MAX_GRAPHICS_FRAME_SIZE) { Ok(ServerMessage::Terminal(frame)) => { let encoded = base64::engine::general_purpose::STANDARD.encode(&frame.bytes); let line = serde_json::json!({ "type": "terminal.frame", "seq": frame.seq, "encoding": "ansi", "width": frame.width, "height": frame.height, "full": frame.full, "bytes": encoded, }); serde_json::to_writer(&mut stdout, &line)?; stdout.write_all(b"\n")?; stdout.flush()?; } Ok(ServerMessage::ServerShutdown { reason }) => { let line = serde_json::json!({ "type": "terminal.closed", "reason": reason, }); serde_json::to_writer(&mut stdout, &line)?; stdout.write_all(b"\n")?; stdout.flush()?; return Ok(()); } Ok(ServerMessage::Graphics { .. }) => {} Ok(_) => {} Err(protocol::FramingError::UnexpectedEof) => return Ok(()), Err(err) => return Err(io::Error::other(err.to_string())), } } } #[derive(serde::Deserialize)] #[serde(tag = "type")] enum TerminalControlCommand { #[serde(rename = "terminal.input")] Input { text: Option, bytes: Option, }, #[serde(rename = "terminal.resize")] Resize { cols: u16, rows: u16, #[serde(default)] cell_width_px: u32, #[serde(default)] cell_height_px: u32, }, #[serde(rename = "terminal.scroll")] Scroll { direction: TerminalControlScrollDirection, lines: u16, #[serde(default)] source: TerminalControlScrollSource, #[serde(default)] column: Option, #[serde(default)] row: Option, #[serde(default)] modifiers: u8, }, #[serde(rename = "terminal.release")] Release {}, } #[derive(Clone, Copy, serde::Deserialize)] #[serde(rename_all = "snake_case")] enum TerminalControlScrollDirection { Up, Down, } #[derive(Clone, Copy, Default, serde::Deserialize)] #[serde(rename_all = "snake_case")] enum TerminalControlScrollSource { #[default] Wheel, PageKey, } pub(super) fn terminal_control_command_from_json(raw: &str) -> Result { let command = serde_json::from_str::(raw) .map_err(|err| format!("invalid json command: {err}"))?; match command { TerminalControlCommand::Input { text, bytes } => { let data = match (text, bytes) { (Some(_), Some(_)) => { return Err("terminal.input accepts text or bytes, not both".into()) } (Some(text), None) => text.into_bytes(), (None, Some(bytes)) => base64::engine::general_purpose::STANDARD .decode(bytes) .map_err(|err| format!("invalid terminal.input bytes: {err}"))?, (None, None) => Vec::new(), }; Ok(ClientMessage::Input { data }) } TerminalControlCommand::Resize { cols, rows, cell_width_px, cell_height_px, } => { if cols == 0 || rows == 0 { return Err("terminal.resize cols and rows must be greater than 0".into()); } Ok(ClientMessage::Resize { cols, rows, cell_width_px, cell_height_px, pixel_mouse: false, }) } TerminalControlCommand::Scroll { direction, lines, source, column, row, modifiers, } => { if lines == 0 { return Err("terminal.scroll lines must be greater than 0".into()); } let direction = match direction { TerminalControlScrollDirection::Up => AttachScrollDirection::Up, TerminalControlScrollDirection::Down => AttachScrollDirection::Down, }; let source = match source { TerminalControlScrollSource::Wheel => AttachScrollSource::Wheel, TerminalControlScrollSource::PageKey => AttachScrollSource::PageKey { input: match direction { AttachScrollDirection::Up => b"\x1b[5~".to_vec(), AttachScrollDirection::Down => b"\x1b[6~".to_vec(), }, }, }; Ok(ClientMessage::AttachScroll { source, direction, lines, column, row, modifiers, }) } TerminalControlCommand::Release {} => Ok(ClientMessage::Detach), } }