aboutsummaryrefslogtreecommitdiff
path: root/src/client
diff options
context:
space:
mode:
Diffstat (limited to 'src/client')
-rw-r--r--src/client/mod.rs258
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");