diff options
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/mod.rs | 258 |
1 files changed, 251 insertions, 7 deletions
diff --git a/src/client/mod.rs b/src/client/mod.rs index 98105781..59b7b114 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -15,7 +15,7 @@ mod input; use std::collections::HashSet; -use std::io::{self, Write as _}; +use std::io::{self, BufRead, Write as _}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -36,12 +36,13 @@ use tracing::{debug, info, warn}; use crate::ipc::LocalStream; use crate::protocol::render_ansi; +#[cfg(unix)] +use crate::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD; use crate::protocol::{ - self, ClientKeybindings, ClientLaunchMode, ClientMessage, NotifyKind, RenderEncoding, - ServerMessage, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode, + ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, + MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; -#[cfg(unix)] -use crate::protocol::{AttachScrollDirection, AttachScrollSource, MAX_CLIPBOARD_IMAGE_PAYLOAD}; use crate::server::socket_paths::client_socket_path; static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); @@ -817,11 +818,70 @@ pub fn run_terminal_attach(_terminal_id: String, _takeover: bool) -> io::Result< /// 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<LocalStream> { init_logging(); let socket_path = client_socket_path(); crate::logging::startup("client"); - info!(path = %socket_path.display(), target = %target, cols, rows, "observing terminal session"); + 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, @@ -853,9 +913,11 @@ pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io: } } - write_to_server(&mut stream, &ClientMessage::ObserveTerminal { target })?; 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) { @@ -892,6 +954,125 @@ pub fn run_terminal_session_observe(target: String, cols: u16, rows: u16) -> io: } } +#[derive(serde::Deserialize)] +#[serde(tag = "type")] +enum TerminalControlCommand { + #[serde(rename = "terminal.input")] + Input { + text: Option<String>, + bytes: Option<String>, + }, + #[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<u16>, + #[serde(default)] + row: Option<u16>, + #[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, +} + +fn terminal_control_command_from_json(raw: &str) -> Result<ClientMessage, String> { + let command = serde_json::from_str::<TerminalControlCommand>(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, + }) + } + 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), + } +} + fn run_client_with_mode( requested_encoding: RenderEncoding, attach_request: Option<(String, bool)>, @@ -2334,6 +2515,69 @@ mod tests { } #[test] + fn terminal_control_input_command_accepts_text() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","text":"hello"}"#) + .unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"hello"); + } + + #[test] + fn terminal_control_input_command_accepts_base64_bytes() { + let action = + terminal_control_command_from_json(r#"{"type":"terminal.input","bytes":"G1tB"}"#) + .unwrap(); + let ClientMessage::Input { data } = action else { + panic!("expected input command"); + }; + assert_eq!(data, b"\x1b[A"); + } + + #[test] + fn terminal_control_resize_command_maps_to_client_resize() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.resize","cols":100,"rows":30,"cell_width_px":8,"cell_height_px":16}"#, + ) + .unwrap(); + let ClientMessage::Resize { + cols, + rows, + cell_width_px, + cell_height_px, + } = action + else { + panic!("expected resize command"); + }; + assert_eq!( + (cols, rows, cell_width_px, cell_height_px), + (100, 30, 8, 16) + ); + } + + #[test] + fn terminal_control_scroll_command_maps_to_attach_scroll() { + let action = terminal_control_command_from_json( + r#"{"type":"terminal.scroll","direction":"up","lines":3}"#, + ) + .unwrap(); + let ClientMessage::AttachScroll { + source, + direction, + lines, + .. + } = action + else { + panic!("expected scroll command"); + }; + assert_eq!(source, AttachScrollSource::Wheel); + assert_eq!(direction, AttachScrollDirection::Up); + assert_eq!(lines, 3); + } + + #[test] fn forward_clipboard_uses_local_clipboard_path() { unsafe { std::env::set_var("SSH_CONNECTION", "1 2 3 4"); |
