feat: add terminal session control stream
This commit is contained in:
parent
f3da280ccc
commit
0fa6440d41
|
|
@ -6,6 +6,7 @@
|
|||
- Added `session.snapshot` to bootstrap client runtime state in one socket API response before subscribing to events.
|
||||
- Added `herdr api schema` to inspect the bundled socket API schema, with `--json` for the full JSON Schema document and `--output PATH` for file output.
|
||||
- Added `herdr terminal session observe` for read-only live ANSI terminal streams that bridge processes can consume as newline-delimited JSON.
|
||||
- Added `herdr terminal session control` for bridge processes that need live ANSI frames plus input, resize, scroll, release, and takeover authority.
|
||||
|
||||
### Changed
|
||||
- Bumped the client/server protocol version to 15 for socket API placement mutation event and response compatibility.
|
||||
|
|
|
|||
|
|
@ -234,12 +234,19 @@ Use `pane send-text`, `pane send-keys`, `pane run`, and `terminal attach` for or
|
|||
|
||||
```bash
|
||||
herdr terminal attach <terminal_id> [--takeover]
|
||||
herdr terminal session control <target> [--takeover] [--cols N] [--rows N]
|
||||
herdr terminal session observe <target> [--cols N] [--rows N]
|
||||
herdr terminal title set <title>
|
||||
herdr terminal title clear
|
||||
```
|
||||
|
||||
Detach from direct attach with `ctrl+b q`. Send literal `ctrl+b` with `ctrl+b ctrl+b`.
|
||||
`terminal session control` opens a writable live terminal stream for a pane,
|
||||
terminal, or agent target. It prints the same newline-delimited
|
||||
`terminal.frame` and `terminal.closed` records as observe mode. It reads
|
||||
newline-delimited JSON commands on stdin: `terminal.input`, `terminal.resize`,
|
||||
`terminal.scroll`, and `terminal.release`. One controller can own a terminal at
|
||||
a time; use `--takeover` to replace it.
|
||||
`terminal session observe` opens a read-only live terminal stream for a pane,
|
||||
terminal, or agent target. It prints newline-delimited JSON `terminal.frame`
|
||||
records with base64-encoded ANSI bytes, then a `terminal.closed` record when
|
||||
|
|
|
|||
|
|
@ -133,6 +133,18 @@ bytes, then a `terminal.closed` record when the server closes the stream.
|
|||
Multiple observers can watch the same terminal without taking input, resize,
|
||||
scroll, or takeover ownership.
|
||||
|
||||
For an interactive bridge, use a writable terminal session controller:
|
||||
|
||||
```bash
|
||||
herdr terminal session control w1:p1 --takeover --cols 120 --rows 40
|
||||
```
|
||||
|
||||
Control mode prints the same newline-delimited frame records and reads
|
||||
newline-delimited JSON commands on stdin. `terminal.input` sends text or
|
||||
base64 bytes, `terminal.resize` changes the controller viewport,
|
||||
`terminal.scroll` scrolls the attached viewport, and `terminal.release` closes
|
||||
the controller. Only one controller owns input and resize at a time.
|
||||
|
||||
## Single-process escape hatch
|
||||
|
||||
Use `--no-session` to run Herdr without the background server/client split:
|
||||
|
|
|
|||
92
src/cli.rs
92
src/cli.rs
|
|
@ -22,6 +22,8 @@ mod worktree;
|
|||
|
||||
const TERMINAL_SESSION_OBSERVE_USAGE: &str =
|
||||
"usage: herdr terminal session observe <target> [--cols N] [--rows N]";
|
||||
const TERMINAL_SESSION_CONTROL_USAGE: &str =
|
||||
"usage: herdr terminal session control <target> [--takeover] [--cols N] [--rows N]";
|
||||
|
||||
pub(crate) fn parse_env_assignment(raw: &str) -> Result<(String, String), String> {
|
||||
let Some((key, value)) = raw.split_once('=') else {
|
||||
|
|
@ -482,66 +484,125 @@ fn terminal_attach(args: &[String]) -> std::io::Result<i32> {
|
|||
|
||||
fn terminal_session(args: &[String]) -> std::io::Result<i32> {
|
||||
match args.first().map(|arg| arg.as_str()) {
|
||||
Some("control") => terminal_session_control(&args[1..]),
|
||||
Some("observe") => terminal_session_observe(&args[1..]),
|
||||
Some("help" | "--help" | "-h") => {
|
||||
eprintln!("{TERMINAL_SESSION_CONTROL_USAGE}");
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
Ok(0)
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{TERMINAL_SESSION_CONTROL_USAGE}");
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
Ok(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_session_control(args: &[String]) -> std::io::Result<i32> {
|
||||
let options = match parse_terminal_session_options(
|
||||
args,
|
||||
TERMINAL_SESSION_CONTROL_USAGE,
|
||||
"control",
|
||||
true,
|
||||
)? {
|
||||
Ok(options) => options,
|
||||
Err(code) => return Ok(code),
|
||||
};
|
||||
|
||||
crate::client::run_terminal_session_control(
|
||||
options.target,
|
||||
options.takeover,
|
||||
options.cols,
|
||||
options.rows,
|
||||
)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
fn terminal_session_observe(args: &[String]) -> std::io::Result<i32> {
|
||||
let options = match parse_terminal_session_options(
|
||||
args,
|
||||
TERMINAL_SESSION_OBSERVE_USAGE,
|
||||
"observe",
|
||||
false,
|
||||
)? {
|
||||
Ok(options) => options,
|
||||
Err(code) => return Ok(code),
|
||||
};
|
||||
|
||||
crate::client::run_terminal_session_observe(options.target, options.cols, options.rows)?;
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
struct TerminalSessionOptions {
|
||||
target: String,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
takeover: bool,
|
||||
}
|
||||
|
||||
fn parse_terminal_session_options(
|
||||
args: &[String],
|
||||
usage: &str,
|
||||
command: &str,
|
||||
allow_takeover: bool,
|
||||
) -> std::io::Result<Result<TerminalSessionOptions, i32>> {
|
||||
if matches!(
|
||||
args.first().map(|arg| arg.as_str()),
|
||||
Some("help" | "--help" | "-h")
|
||||
) {
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(0);
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(0));
|
||||
}
|
||||
let Some(target) = args.first() else {
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(2);
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(2));
|
||||
};
|
||||
|
||||
let mut cols = 120;
|
||||
let mut rows = 40;
|
||||
let mut takeover = false;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"--takeover" if allow_takeover => {
|
||||
takeover = true;
|
||||
i += 1;
|
||||
}
|
||||
"--cols" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(2);
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(2));
|
||||
};
|
||||
cols = parse_terminal_dimension(value, "--cols")?;
|
||||
i += 2;
|
||||
}
|
||||
"--rows" => {
|
||||
let Some(value) = args.get(i + 1) else {
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(2);
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(2));
|
||||
};
|
||||
rows = parse_terminal_dimension(value, "--rows")?;
|
||||
i += 2;
|
||||
}
|
||||
"help" | "--help" | "-h" => {
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(0);
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(0));
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown terminal session observe option: {other}");
|
||||
eprintln!("{TERMINAL_SESSION_OBSERVE_USAGE}");
|
||||
return Ok(2);
|
||||
eprintln!("unknown terminal session {command} option: {other}");
|
||||
eprintln!("{usage}");
|
||||
return Ok(Err(2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crate::client::run_terminal_session_observe(target.clone(), cols, rows)?;
|
||||
Ok(0)
|
||||
Ok(Ok(TerminalSessionOptions {
|
||||
target: target.clone(),
|
||||
cols,
|
||||
rows,
|
||||
takeover,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_terminal_dimension(raw: &str, flag: &str) -> std::io::Result<u16> {
|
||||
|
|
@ -1000,6 +1061,7 @@ fn print_config_help() {
|
|||
fn print_terminal_help() {
|
||||
eprintln!("herdr terminal commands:");
|
||||
eprintln!(" herdr terminal attach <terminal_id> [--takeover]");
|
||||
eprintln!(" herdr terminal session control <target> [--takeover] [--cols N] [--rows N]");
|
||||
eprintln!(" herdr terminal session observe <target> [--cols N] [--rows N]");
|
||||
eprintln!(" herdr terminal title set <title>");
|
||||
eprintln!(" herdr terminal title clear");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
use crate::protocol::{
|
||||
self, 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::protocol::MAX_CLIPBOARD_IMAGE_PAYLOAD;
|
||||
use crate::protocol::{
|
||||
self, AttachScrollDirection, AttachScrollSource, ClientKeybindings, ClientLaunchMode,
|
||||
ClientMessage, NotifyKind, RenderEncoding, ServerMessage, MAX_FRAME_SIZE,
|
||||
MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
|
||||
};
|
||||
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)>,
|
||||
|
|
@ -2333,6 +2514,69 @@ mod tests {
|
|||
assert_eq!(decode_clipboard_payload("not-base64!!!"), None);
|
||||
}
|
||||
|
||||
#[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 {
|
||||
|
|
|
|||
|
|
@ -387,6 +387,14 @@ pub enum ClientMessage {
|
|||
/// Pane, terminal, or agent target to observe.
|
||||
target: String,
|
||||
},
|
||||
|
||||
/// Switch this connection into writable terminal control mode.
|
||||
ControlTerminal {
|
||||
/// Pane, terminal, or agent target to control.
|
||||
target: String,
|
||||
/// Replace an existing writable controller for this terminal.
|
||||
takeover: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -1005,6 +1013,13 @@ mod tests {
|
|||
}),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
tag(&ClientMessage::ControlTerminal {
|
||||
target: "w1:p1".to_owned(),
|
||||
takeover: false,
|
||||
}),
|
||||
9
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1143,6 +1158,18 @@ mod tests {
|
|||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_control_terminal_roundtrip() {
|
||||
let msg = ClientMessage::ControlTerminal {
|
||||
target: "w1:p1".to_owned(),
|
||||
takeover: true,
|
||||
};
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ClientMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_attach_scroll_roundtrip() {
|
||||
let msg = ClientMessage::AttachScroll {
|
||||
|
|
|
|||
|
|
@ -314,6 +314,12 @@ pub(crate) enum ServerEvent {
|
|||
},
|
||||
/// A client requested read-only observation of one terminal.
|
||||
ClientObserveTerminal { client_id: u64, target: String },
|
||||
/// A client requested writable control of one terminal.
|
||||
ClientControlTerminal {
|
||||
client_id: u64,
|
||||
target: String,
|
||||
takeover: bool,
|
||||
},
|
||||
/// A direct terminal attach client requested scrollback movement.
|
||||
ClientAttachScroll {
|
||||
client_id: u64,
|
||||
|
|
@ -673,6 +679,13 @@ fn client_read_loop(
|
|||
ClientMessage::ObserveTerminal { target } => {
|
||||
ServerEvent::ClientObserveTerminal { client_id, target }
|
||||
}
|
||||
ClientMessage::ControlTerminal { target, takeover } => {
|
||||
ServerEvent::ClientControlTerminal {
|
||||
client_id,
|
||||
target,
|
||||
takeover,
|
||||
}
|
||||
}
|
||||
ClientMessage::ClipboardImage { extension, data } => {
|
||||
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
|
||||
warn!(
|
||||
|
|
|
|||
|
|
@ -1368,31 +1368,46 @@ impl HeadlessServer {
|
|||
true
|
||||
}
|
||||
|
||||
fn observe_terminal_client(&mut self, client_id: u64, target: String) -> bool {
|
||||
fn resolve_terminal_session_target(
|
||||
&mut self,
|
||||
client_id: u64,
|
||||
target: &str,
|
||||
action: &str,
|
||||
) -> Option<String> {
|
||||
if !self.client_is_pending_terminal_mode(client_id) {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(
|
||||
"terminal session observe failed: connection is not pending terminal session"
|
||||
.to_owned(),
|
||||
format!(
|
||||
"terminal session {action} failed: connection is not pending terminal session"
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
|
||||
let Some(terminal_id) = self.resolve_terminal_target_id_string(&target) else {
|
||||
let Some(terminal_id) = self.resolve_terminal_target_id_string(target) else {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some(format!(
|
||||
"terminal session observe failed: terminal target {target} not found"
|
||||
"terminal session {action} failed: terminal target {target} not found"
|
||||
)),
|
||||
},
|
||||
);
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
return None;
|
||||
};
|
||||
|
||||
Some(terminal_id)
|
||||
}
|
||||
|
||||
fn observe_terminal_client(&mut self, client_id: u64, target: String) -> bool {
|
||||
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "observe")
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
@ -1416,6 +1431,15 @@ impl HeadlessServer {
|
|||
true
|
||||
}
|
||||
|
||||
fn control_terminal_client(&mut self, client_id: u64, target: String, takeover: bool) -> bool {
|
||||
let Some(terminal_id) = self.resolve_terminal_session_target(client_id, &target, "control")
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
self.attach_terminal_client(client_id, terminal_id, takeover)
|
||||
}
|
||||
|
||||
fn handle_terminal_attach_scroll(
|
||||
&mut self,
|
||||
client_id: u64,
|
||||
|
|
@ -2146,6 +2170,23 @@ impl HeadlessServer {
|
|||
}
|
||||
}
|
||||
|
||||
fn send_terminal_stream_detach_shutdown(&mut self, client_id: u64) {
|
||||
if matches!(
|
||||
self.clients.get(&client_id).map(|client| &client.mode),
|
||||
Some(
|
||||
ClientConnectionMode::TerminalAttach { .. }
|
||||
| ClientConnectionMode::TerminalObserve { .. }
|
||||
)
|
||||
) {
|
||||
self.send_to_client(
|
||||
client_id,
|
||||
ServerMessage::ServerShutdown {
|
||||
reason: Some("detached".to_owned()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn disconnect_all_clients_for_handoff(&mut self) {
|
||||
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
|
||||
|
|
@ -2405,6 +2446,11 @@ impl HeadlessServer {
|
|||
ServerEvent::ClientObserveTerminal { client_id, target } => {
|
||||
self.observe_terminal_client(client_id, target)
|
||||
}
|
||||
ServerEvent::ClientControlTerminal {
|
||||
client_id,
|
||||
target,
|
||||
takeover,
|
||||
} => self.control_terminal_client(client_id, target, takeover),
|
||||
ServerEvent::ClientAttachScroll {
|
||||
client_id,
|
||||
source,
|
||||
|
|
@ -2571,6 +2617,7 @@ impl HeadlessServer {
|
|||
}
|
||||
ServerEvent::ClientDetach { client_id } => {
|
||||
info!(client_id, "client detached");
|
||||
self.send_terminal_stream_detach_shutdown(client_id);
|
||||
self.remove_client_and_resize_if_needed(client_id);
|
||||
true
|
||||
}
|
||||
|
|
@ -4536,7 +4583,14 @@ next_tab = ""
|
|||
}
|
||||
|
||||
fn connect_pending_terminal_client(server: &mut HeadlessServer, client_id: u64) {
|
||||
let (writer, _control_rx, _render_rx) = test_client_writer();
|
||||
let _control_rx = connect_pending_terminal_client_with_control_rx(server, client_id);
|
||||
}
|
||||
|
||||
fn connect_pending_terminal_client_with_control_rx(
|
||||
server: &mut HeadlessServer,
|
||||
client_id: u64,
|
||||
) -> std::sync::mpsc::Receiver<Vec<u8>> {
|
||||
let (writer, control_rx, _render_rx) = test_client_writer();
|
||||
assert!(server.handle_server_event(ServerEvent::ClientConnected {
|
||||
client_id,
|
||||
cols: 100,
|
||||
|
|
@ -4548,6 +4602,7 @@ next_tab = ""
|
|||
direct_attach_requested: true,
|
||||
writer,
|
||||
}));
|
||||
control_rx
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4611,6 +4666,156 @@ next_tab = ""
|
|||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_resolves_public_pane_id_and_takes_ownership() {
|
||||
with_terminal_session_test_server(
|
||||
|server, terminal_id, terminal_id_string, public_pane_id| {
|
||||
connect_pending_terminal_client(server, 7);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: public_pane_id,
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
server.clients.get(&7).map(|client| &client.mode),
|
||||
Some(ClientConnectionMode::TerminalAttach { terminal_id: attached })
|
||||
if attached == &terminal_id_string
|
||||
));
|
||||
assert_eq!(
|
||||
server.terminal_attach_owners.get(&terminal_id_string),
|
||||
Some(&7)
|
||||
);
|
||||
assert!(server
|
||||
.app
|
||||
.state
|
||||
.direct_attach_resize_locks
|
||||
.contains(&terminal_id));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_rejects_second_controller_without_takeover() {
|
||||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||||
connect_pending_terminal_client(server, 7);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
connect_pending_terminal_client(server, 8);
|
||||
assert!(
|
||||
!server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 8,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
assert!(server.clients.contains_key(&7));
|
||||
assert!(!server.clients.contains_key(&8));
|
||||
assert_eq!(
|
||||
server.terminal_attach_owners.get(&terminal_id_string),
|
||||
Some(&7)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_takeover_replaces_existing_controller() {
|
||||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||||
connect_pending_terminal_client(server, 7);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
connect_pending_terminal_client(server, 8);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 8,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: true,
|
||||
})
|
||||
);
|
||||
|
||||
assert!(!server.clients.contains_key(&7));
|
||||
assert!(server.clients.contains_key(&8));
|
||||
assert_eq!(
|
||||
server.terminal_attach_owners.get(&terminal_id_string),
|
||||
Some(&8)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observe_can_coexist_with_terminal_control() {
|
||||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||||
connect_pending_terminal_client(server, 7);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
connect_pending_terminal_client(server, 8);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientObserveTerminal {
|
||||
client_id: 8,
|
||||
target: terminal_id_string.clone(),
|
||||
})
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
server.terminal_attach_owners.get(&terminal_id_string),
|
||||
Some(&7)
|
||||
);
|
||||
assert!(matches!(
|
||||
server.clients.get(&8).map(|client| &client.mode),
|
||||
Some(ClientConnectionMode::TerminalObserve { terminal_id })
|
||||
if terminal_id == &terminal_id_string
|
||||
));
|
||||
assert_eq!(
|
||||
terminal_stream_client_ids(&server.clients, &terminal_id_string).len(),
|
||||
2
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_control_detach_sends_shutdown_before_removal() {
|
||||
with_terminal_session_test_server(|server, _terminal_id, terminal_id_string, _| {
|
||||
let control_rx = connect_pending_terminal_client_with_control_rx(server, 7);
|
||||
assert!(
|
||||
server.handle_server_event(ServerEvent::ClientControlTerminal {
|
||||
client_id: 7,
|
||||
target: terminal_id_string.clone(),
|
||||
takeover: false,
|
||||
})
|
||||
);
|
||||
|
||||
assert!(server.handle_server_event(ServerEvent::ClientDetach { client_id: 7 }));
|
||||
|
||||
assert!(!server.clients.contains_key(&7));
|
||||
assert!(!server
|
||||
.terminal_attach_owners
|
||||
.contains_key(&terminal_id_string));
|
||||
let reason = read_server_shutdown_reason(control_rx.recv().expect("shutdown message"));
|
||||
assert_eq!(reason, Some("detached".to_owned()));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_observe_rejects_later_attach_upgrade() {
|
||||
with_terminal_session_test_server(|server, terminal_id, terminal_id_string, _| {
|
||||
|
|
|
|||
Loading…
Reference in New Issue