aboutsummaryrefslogtreecommitdiff
path: root/src/server/client_transport.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/client_transport.rs')
-rw-r--r--src/server/client_transport.rs1097
1 files changed, 573 insertions, 524 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs
index f1237c89..673e16d3 100644
--- a/src/server/client_transport.rs
+++ b/src/server/client_transport.rs
@@ -18,9 +18,9 @@ use tracing::{debug, warn};
use crate::ipc::LocalStream;
use crate::protocol::{
- self, AttachScrollDirection, AttachScrollSource, ClientInputEvent, ClientKeybindings,
- ClientLaunchMode, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD,
- MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
+ self, AttachScrollDirection, AttachScrollSource, ClientMessage, ClientPaneInputEvent,
+ RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE,
+ MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION,
};
/// Minimum accepted attached client size.
@@ -41,8 +41,6 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(4);
const MAX_INPUT_PAYLOAD: usize = 1024 * 1024; // 1 MB
/// Maximum structured input events accepted in one client message.
const MAX_INPUT_EVENT_BATCH: usize = 4096;
-/// Maximum encoded mouse report accepted with pixel geometry.
-const MAX_PIXEL_MOUSE_PAYLOAD: usize = 128;
/// Channels owned by the server side of a client writer thread.
#[derive(Clone, Debug)]
@@ -54,10 +52,6 @@ pub(crate) struct ClientWriter {
}
impl ClientWriter {
- pub(crate) fn replace_with_cleanup(&self, data: Vec<u8>) {
- self.render.queue.replace_with_cleanup(data);
- }
-
#[cfg(test)]
pub(crate) fn test_fill_render(&self, data: Vec<u8>) {
self.render.try_send(data).unwrap();
@@ -254,16 +248,6 @@ impl ClientWriterQueue {
Ok(())
}
- fn replace_with_cleanup(&self, data: Vec<u8>) {
- let mut state = self.lock_state();
- state.render = None;
- state.ordered.clear();
- if state.writer_alive {
- state.control.push_back(data);
- self.ready.notify_one();
- }
- }
-
fn recv(&self) -> Option<ClientWriteItem> {
let mut state = self.lock_state();
loop {
@@ -312,10 +296,20 @@ pub(crate) enum ServerEvent {
rows: u16,
cell_width_px: u32,
cell_height_px: u32,
- render_encoding: RenderEncoding,
- keybindings: Option<Box<crate::config::LiveKeybindConfig>>,
- direct_attach_requested: bool,
+ pixel_mouse: bool,
+ writer: ClientWriter,
+ },
+ /// A client-owned shell completed its dedicated handshake.
+ ClientShellConnected {
+ client_id: u64,
+ surface_cols: u16,
+ surface_rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ pixel_mouse: bool,
direct_graphics: bool,
+ endpoint_keybindings: bool,
+ mouse_capture: bool,
writer: ClientWriter,
},
/// A client sent an input message.
@@ -332,17 +326,6 @@ pub(crate) enum ServerEvent {
transfer_id: u64,
image_id: u32,
},
- /// One confirmed SGR pixel report with client read-time geometry.
- ClientInputPixels {
- client_id: u64,
- data: Vec<u8>,
- geometry: crate::input::mouse::HostGeometry,
- },
- /// A client sent structured input events.
- ClientInputEvents {
- client_id: u64,
- events: Vec<crate::protocol::ClientInputEvent>,
- },
/// A fully decoded interactive paste exceeded the text-input limit.
ClientPasteRejected {
client_id: u64,
@@ -352,6 +335,7 @@ pub(crate) enum ServerEvent {
/// A client sent local clipboard image bytes to paste into a remote pane.
ClientClipboardImage {
client_id: u64,
+ target: crate::protocol::ClientClipboardImageTarget,
extension: String,
data: Vec<u8>,
},
@@ -379,6 +363,15 @@ pub(crate) enum ServerEvent {
row: Option<u16>,
modifiers: u8,
},
+ /// A direct terminal attach client delivered one structured mouse event.
+ ClientAttachMouse {
+ client_id: u64,
+ kind: crate::protocol::ClientMouseKind,
+ position: crate::protocol::ClientMousePosition,
+ geometry: Option<crate::protocol::ClientMouseGeometry>,
+ modifiers: u8,
+ lines: u16,
+ },
/// A client sent a resize message.
ClientResize {
client_id: u64,
@@ -386,6 +379,51 @@ pub(crate) enum ServerEvent {
rows: u16,
cell_width_px: u32,
cell_height_px: u32,
+ pixel_mouse: bool,
+ },
+ /// A client-owned shell recomputed its pane viewport.
+ ClientShellResize {
+ client_id: u64,
+ surface_cols: u16,
+ surface_rows: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ pixel_mouse: bool,
+ },
+ /// A client-owned shell delivered semantic input to one stable pane target.
+ ClientShellPaneInput {
+ client_id: u64,
+ pane_id: String,
+ events: Vec<ClientPaneInputEvent>,
+ },
+ /// A client-owned shell delivered semantic input to its active popup terminal.
+ ClientShellPopupInput {
+ client_id: u64,
+ terminal_id: String,
+ events: Vec<ClientPaneInputEvent>,
+ },
+ /// A client-owned shell published one host terminal theme observation.
+ ClientShellHostTheme {
+ client_id: u64,
+ update: crate::protocol::ClientHostThemeUpdate,
+ },
+ /// A client-owned shell reported whether its outer terminal has focus.
+ ClientShellFocus { client_id: u64, focused: bool },
+ /// A client-owned shell updated its local mouse-capture preference.
+ ClientShellMouseCapture { client_id: u64, enabled: bool },
+ /// A client-owned shell invoked one endpoint operation through this connection.
+ ClientShellEndpointRequest {
+ client_id: u64,
+ boot_id: String,
+ request: Box<crate::api::schema::Request>,
+ },
+ /// One chunk of a deferred endpoint operation's final response is ready.
+ ClientShellEndpointResponseChunkReady {
+ client_id: u64,
+ boot_id: String,
+ request_id: String,
+ final_chunk: bool,
+ data: Vec<u8>,
},
/// A client detached gracefully.
ClientDetach { client_id: u64 },
@@ -404,23 +442,6 @@ pub(crate) fn clamp_terminal_size(cols: u16, rows: u16) -> (u16, u16) {
(clamped_cols, clamped_rows)
}
-fn parse_client_keybindings(
- keybindings: ClientKeybindings,
-) -> Result<Option<Box<crate::config::LiveKeybindConfig>>, String> {
- match keybindings {
- ClientKeybindings::Server => Ok(None),
- ClientKeybindings::Local { keys_toml } => {
- let mut config = toml::from_str::<crate::config::Config>(&keys_toml)
- .map_err(|err| format!("invalid client keybindings: {err}"))?;
- config.keys.command.clear();
- Ok(Some(Box::new(crate::config::LiveKeybindConfig {
- prefix: config.prefix_key(),
- keybinds: config.keybinds(),
- })))
- }
- }
-}
-
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum InputEventLimit {
WithinLimits,
@@ -429,20 +450,28 @@ enum InputEventLimit {
InputPayloadTooLarge { size: usize },
}
-fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
+fn pane_input_event_limit(events: &[ClientPaneInputEvent]) -> InputEventLimit {
let mut expanded_events = 0usize;
let mut paste_bytes = 0usize;
let mut input_bytes = 0usize;
for event in events {
expanded_events = expanded_events.saturating_add(match event {
- ClientInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)),
- _ => 1,
+ ClientPaneInputEvent::Key { repeat_count, .. } => usize::from((*repeat_count).max(1)),
+ ClientPaneInputEvent::Mouse {
+ kind:
+ crate::protocol::ClientMouseKind::ScrollUp
+ | crate::protocol::ClientMouseKind::ScrollDown,
+ lines,
+ ..
+ } => usize::from((*lines).max(1)),
+ ClientPaneInputEvent::TextCommit(_)
+ | ClientPaneInputEvent::Mouse { .. }
+ | ClientPaneInputEvent::Paste(_) => 1,
});
match event {
- ClientInputEvent::Key {
+ ClientPaneInputEvent::Key {
repeat_count,
generated_text,
- source,
..
} => {
if let Some(text) = generated_text {
@@ -451,22 +480,25 @@ fn input_event_limit(events: &[ClientInputEvent]) -> InputEventLimit {
.saturating_mul(usize::from((*repeat_count).max(1))),
);
}
- if let crate::protocol::ClientKeySource::Vt { bytes } = source {
- input_bytes = input_bytes.saturating_add(bytes.len());
- }
}
- ClientInputEvent::TextCommit(text) => {
+ ClientPaneInputEvent::TextCommit(text) => {
input_bytes = input_bytes.saturating_add(text.len());
}
- ClientInputEvent::Paste { text } => {
+ ClientPaneInputEvent::Mouse { .. } => {}
+ ClientPaneInputEvent::Paste(text) => {
paste_bytes = paste_bytes.saturating_add(text.len());
}
- ClientInputEvent::Mouse { .. }
- | ClientInputEvent::FocusGained
- | ClientInputEvent::FocusLost => {}
}
}
+ classify_input_event_size(expanded_events, paste_bytes, input_bytes)
+}
+
+fn classify_input_event_size(
+ expanded_events: usize,
+ paste_bytes: usize,
+ input_bytes: usize,
+) -> InputEventLimit {
if expanded_events > MAX_INPUT_EVENT_BATCH {
return InputEventLimit::TooManyEvents;
}
@@ -514,8 +546,8 @@ fn set_client_recv_timeout(
/// Handles the client handshake on a blocking thread.
///
-/// Reads the `Hello` message, validates the version, sends `Welcome`,
-/// and then enters a read loop forwarding messages to the server event channel.
+/// Reads the `TerminalHello` or `ClientShellHello` message, validates the version,
+/// sends `Welcome`, and then enters a read loop forwarding messages to the server event channel.
pub(crate) fn handle_client_handshake(
mut stream: LocalStream,
client_id: u64,
@@ -537,7 +569,7 @@ pub(crate) fn handle_client_handshake(
client_id,
)?;
- // Read the Hello message.
+ // Read the handshake message.
let hello: ClientMessage = match protocol::read_message(&mut stream, MAX_FRAME_SIZE) {
Ok(msg) => msg,
Err(protocol::FramingError::UnexpectedEof) => {
@@ -559,69 +591,83 @@ pub(crate) fn handle_client_handshake(
client_rows,
cell_width_px,
cell_height_px,
- render_encoding,
- keybindings,
- direct_attach_requested,
- direct_graphics,
+ terminal_pixel_mouse,
+ shell_options,
) = match hello {
- ClientMessage::Hello {
+ ClientMessage::TerminalHello {
version,
cols,
rows,
cell_width_px,
cell_height_px,
- requested_encoding,
- keybindings,
- launch_mode,
+ pixel_mouse,
} => {
- // Version check.
- match protocol::check_client_version(version) {
- protocol::VersionCheck::Compatible => {}
- protocol::VersionCheck::Incompatible(reason) => {
- // Send rejection Welcome.
- let welcome = ServerMessage::Welcome {
- version: PROTOCOL_VERSION,
- encoding: RenderEncoding::SemanticFrame,
- error: Some(reason),
- };
- let _ = protocol::write_message(&mut stream, &welcome);
- return Ok(());
- }
+ if let protocol::VersionCheck::Incompatible(reason) =
+ protocol::check_client_version(version)
+ {
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::TerminalAnsi,
+ error: Some(reason),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ let (cols, rows) = clamp_terminal_size(cols, rows);
+ (cols, rows, cell_width_px, cell_height_px, pixel_mouse, None)
+ }
+ ClientMessage::ClientShellHello {
+ version,
+ cell_width_px,
+ cell_height_px,
+ surface_size,
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ } => {
+ if let protocol::VersionCheck::Incompatible(reason) =
+ protocol::check_client_version(version)
+ {
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some(reason),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
+ }
+ if surface_size.cols == 0 || surface_size.rows == 0 {
+ let welcome = ServerMessage::Welcome {
+ version: PROTOCOL_VERSION,
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some("client shell requires a non-empty pane surface".to_owned()),
+ };
+ let _ = protocol::write_message(&mut stream, &welcome);
+ return Ok(());
}
-
- let keybindings = match parse_client_keybindings(keybindings) {
- Ok(keybindings) => keybindings,
- Err(error) => {
- let welcome = ServerMessage::Welcome {
- version: PROTOCOL_VERSION,
- encoding: RenderEncoding::SemanticFrame,
- error: Some(error),
- };
- let _ = protocol::write_message(&mut stream, &welcome);
- return Ok(());
- }
- };
-
- // Clamp size.
- let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
(
- clamped_cols,
- clamped_rows,
+ surface_size.cols,
+ surface_size.rows,
cell_width_px,
cell_height_px,
- requested_encoding,
- keybindings,
- launch_mode == ClientLaunchMode::TerminalAttach,
- launch_mode == ClientLaunchMode::AppDirectGraphics,
+ false,
+ Some((
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ )),
)
}
_ => {
- // First message must be Hello.
- debug!(client_id, "first message was not Hello, closing");
+ debug!(client_id, "first message was not a handshake, closing");
let welcome = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
encoding: RenderEncoding::SemanticFrame,
- error: Some("expected Hello as first message".to_owned()),
+ error: Some(
+ "expected TerminalHello or ClientShellHello as first message".to_owned(),
+ ),
};
let _ = protocol::write_message(&mut stream, &welcome);
return Ok(());
@@ -633,6 +679,11 @@ pub(crate) fn handle_client_handshake(
}
// Send Welcome.
+ let render_encoding = if shell_options.is_some() {
+ RenderEncoding::SemanticFrame
+ } else {
+ RenderEncoding::TerminalAnsi
+ };
let welcome = ServerMessage::Welcome {
version: PROTOCOL_VERSION,
encoding: render_encoding,
@@ -667,21 +718,40 @@ pub(crate) fn handle_client_handshake(
}
// Notify the main loop about the new client.
- let connected = ServerEvent::ClientConnected {
- client_id,
- cols: client_cols,
- rows: client_rows,
- cell_width_px,
- cell_height_px,
- render_encoding,
- keybindings,
- direct_attach_requested,
- direct_graphics,
- writer,
- };
+ let connected =
+ if let Some((pixel_mouse, direct_graphics, endpoint_keybindings, mouse_capture)) =
+ shell_options
+ {
+ ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols: client_cols,
+ surface_rows: client_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
+ writer,
+ }
+ } else {
+ ServerEvent::ClientConnected {
+ client_id,
+ cols: client_cols,
+ rows: client_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse: terminal_pixel_mouse,
+ writer,
+ }
+ };
if let Err(err) = server_event_tx.blocking_send(connected) {
- if let ServerEvent::ClientConnected { writer, .. } = err.0 {
- send_shutdown_to_unregistered_client(&writer);
+ match err.0 {
+ ServerEvent::ClientConnected { writer, .. }
+ | ServerEvent::ClientShellConnected { writer, .. } => {
+ send_shutdown_to_unregistered_client(&writer);
+ }
+ _ => {}
}
}
@@ -804,86 +874,6 @@ fn client_read_loop(
ServerEvent::ClientInput { client_id, data }
}
}
- ClientMessage::InputPixels {
- data,
- cols,
- rows,
- width_px,
- height_px,
- } => {
- let Some(geometry) =
- crate::input::mouse::HostGeometry::new(cols, rows, width_px, height_px)
- else {
- warn!(
- client_id,
- cols,
- rows,
- width_px,
- height_px,
- "invalid pixel mouse geometry from client, closing"
- );
- let _ = server_event_tx
- .blocking_send(ServerEvent::ClientDisconnected { client_id });
- break;
- };
- if data.len() > MAX_PIXEL_MOUSE_PAYLOAD
- || crate::input::mouse::parse_report(&data).is_none()
- {
- warn!(
- client_id,
- size = data.len(),
- max = MAX_PIXEL_MOUSE_PAYLOAD,
- "invalid pixel mouse report from client, closing"
- );
- let _ = server_event_tx
- .blocking_send(ServerEvent::ClientDisconnected { client_id });
- break;
- }
- ServerEvent::ClientInputPixels {
- client_id,
- data,
- geometry,
- }
- }
- ClientMessage::InputEvents { events } => match input_event_limit(&events) {
- InputEventLimit::WithinLimits => {
- ServerEvent::ClientInputEvents { client_id, events }
- }
- InputEventLimit::TooManyEvents => {
- warn!(
- client_id,
- count = events.len(),
- "oversized input event batch from client, closing"
- );
- let _ = server_event_tx
- .blocking_send(ServerEvent::ClientDisconnected { client_id });
- break;
- }
- InputEventLimit::PasteTooLarge { size } => {
- warn!(
- client_id,
- size,
- max = MAX_INPUT_PAYLOAD,
- "oversized structured paste from client, rejecting"
- );
- ServerEvent::ClientPasteRejected {
- client_id,
- size,
- max: MAX_INPUT_PAYLOAD,
- }
- }
- InputEventLimit::InputPayloadTooLarge { size } => {
- warn!(
- client_id,
- size,
- max = MAX_INPUT_PAYLOAD,
- "oversized structured input payload from client, closing"
- );
- let _ = server_event_tx
- .blocking_send(ServerEvent::ClientDisconnected { client_id });
- break;
- }
- },
ClientMessage::ObserveTerminal { target } => {
ServerEvent::ClientObserveTerminal { client_id, target }
}
@@ -912,7 +902,11 @@ fn client_read_loop(
transfer_id,
image_id,
},
- ClientMessage::ClipboardImage { extension, data } => {
+ ClientMessage::ClipboardImage {
+ target,
+ extension,
+ data,
+ } => {
if data.len() > MAX_CLIPBOARD_IMAGE_PAYLOAD {
warn!(
client_id,
@@ -925,6 +919,7 @@ fn client_read_loop(
} else {
ServerEvent::ClientClipboardImage {
client_id,
+ target,
extension,
data,
}
@@ -935,6 +930,7 @@ fn client_read_loop(
rows,
cell_width_px,
cell_height_px,
+ pixel_mouse,
} => {
let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows);
ServerEvent::ClientResize {
@@ -943,9 +939,169 @@ fn client_read_loop(
rows: clamped_rows,
cell_width_px,
cell_height_px,
+ pixel_mouse,
+ }
+ }
+ ClientMessage::ClientShellResize {
+ cell_width_px,
+ cell_height_px,
+ surface_size,
+ pixel_mouse,
+ } => ServerEvent::ClientShellResize {
+ client_id,
+ surface_cols: surface_size.cols.max(1),
+ surface_rows: surface_size.rows.max(1),
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ },
+ ClientMessage::ClientShellHostTheme { update } => {
+ if matches!(
+ &update,
+ crate::protocol::ClientHostThemeUpdate::PaletteColors(colors)
+ if colors.len() > 256
+ ) {
+ warn!(client_id, "invalid client shell host theme update, closing");
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ ServerEvent::ClientShellHostTheme { client_id, update }
+ }
+ ClientMessage::ClientShellFocus { focused } => {
+ ServerEvent::ClientShellFocus { client_id, focused }
+ }
+ ClientMessage::ClientShellMouseCapture { enabled } => {
+ ServerEvent::ClientShellMouseCapture { client_id, enabled }
+ }
+ ClientMessage::ClientShellPaneInput { pane_id, events } => {
+ match pane_input_event_limit(&events) {
+ InputEventLimit::WithinLimits => ServerEvent::ClientShellPaneInput {
+ client_id,
+ pane_id,
+ events,
+ },
+ InputEventLimit::TooManyEvents => {
+ warn!(
+ client_id,
+ count = events.len(),
+ "oversized targeted pane input batch, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ InputEventLimit::PasteTooLarge { size } => {
+ warn!(
+ client_id,
+ size,
+ max = MAX_INPUT_PAYLOAD,
+ "oversized targeted pane paste, rejecting"
+ );
+ ServerEvent::ClientPasteRejected {
+ client_id,
+ size,
+ max: MAX_INPUT_PAYLOAD,
+ }
+ }
+ InputEventLimit::InputPayloadTooLarge { size } => {
+ warn!(
+ client_id,
+ size,
+ max = MAX_INPUT_PAYLOAD,
+ "oversized targeted pane input, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
}
}
- ClientMessage::Detach => ServerEvent::ClientDetach { client_id },
+ ClientMessage::ClientShellPopupInput {
+ terminal_id,
+ events,
+ } => match pane_input_event_limit(&events) {
+ InputEventLimit::WithinLimits => ServerEvent::ClientShellPopupInput {
+ client_id,
+ terminal_id,
+ events,
+ },
+ InputEventLimit::TooManyEvents => {
+ warn!(
+ client_id,
+ count = events.len(),
+ "oversized popup input batch, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ InputEventLimit::PasteTooLarge { size } => {
+ warn!(
+ client_id,
+ size,
+ max = MAX_INPUT_PAYLOAD,
+ "oversized popup paste, rejecting"
+ );
+ ServerEvent::ClientPasteRejected {
+ client_id,
+ size,
+ max: MAX_INPUT_PAYLOAD,
+ }
+ }
+ InputEventLimit::InputPayloadTooLarge { size } => {
+ warn!(
+ client_id,
+ size,
+ max = MAX_INPUT_PAYLOAD,
+ "oversized popup input, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ },
+ ClientMessage::ClientShellEndpointRequest { boot_id, request } => {
+ if boot_id.len() > crate::server::client_commands::MAX_ENDPOINT_BOOT_ID_BYTES
+ || request.len() > crate::server::client_commands::MAX_ENDPOINT_COMMAND_BYTES
+ {
+ warn!(
+ client_id,
+ boot_id_size = boot_id.len(),
+ request_size = request.len(),
+ "oversized client shell endpoint command, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ let Ok(request) = serde_json::from_str::<crate::api::schema::Request>(&request)
+ else {
+ warn!(client_id, "invalid client shell endpoint command, closing");
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ };
+ if request.id.len() > crate::server::client_commands::MAX_ENDPOINT_REQUEST_ID_BYTES
+ {
+ warn!(
+ client_id,
+ "oversized client shell endpoint request id, closing"
+ );
+ let _ = server_event_tx
+ .blocking_send(ServerEvent::ClientDisconnected { client_id });
+ break;
+ }
+ ServerEvent::ClientShellEndpointRequest {
+ client_id,
+ boot_id,
+ request: Box::new(request),
+ }
+ }
+ ClientMessage::Detach => {
+ let _ = server_event_tx.blocking_send(ServerEvent::ClientDetach { client_id });
+ break;
+ }
ClientMessage::AttachTerminal {
terminal_id,
takeover,
@@ -970,8 +1126,22 @@ fn client_read_loop(
row,
modifiers,
},
- ClientMessage::Hello { .. } => {
- // Duplicate Hello — ignore.
+ ClientMessage::AttachMouse {
+ kind,
+ position,
+ geometry,
+ modifiers,
+ lines,
+ } => ServerEvent::ClientAttachMouse {
+ client_id,
+ kind,
+ position,
+ geometry,
+ modifiers,
+ lines,
+ },
+ ClientMessage::TerminalHello { .. } | ClientMessage::ClientShellHello { .. } => {
+ // Duplicate handshake — ignore.
continue;
}
};
@@ -1262,54 +1432,6 @@ mod tests {
}
#[test]
- fn parse_client_keybindings_accepts_local_profile() {
- let keybindings = parse_client_keybindings(ClientKeybindings::Local {
- keys_toml: r#"
-[keys]
-prefix = "ctrl+a"
-new_tab = "prefix+t"
-
-[[keys.command]]
-key = "prefix+g"
-command = "lazygit"
-"#
- .to_owned(),
- })
- .expect("valid client keybindings")
- .expect("local profile");
-
- assert_eq!(keybindings.prefix.0, crossterm::event::KeyCode::Char('a'));
- assert!(keybindings
- .keybinds
- .new_tab
- .bindings
- .iter()
- .any(|binding| binding.label == "prefix+t"));
- assert!(keybindings.keybinds.custom_commands.is_empty());
- }
-
- #[test]
- fn parse_client_keybindings_tolerates_disabled_bindings() {
- let keybindings = parse_client_keybindings(ClientKeybindings::Local {
- keys_toml: r#"
-[keys]
-new_tab = "ctrl+notakey"
-"#
- .to_owned(),
- })
- .expect("diagnostic-only client keybindings should be accepted")
- .expect("local profile");
-
- assert!(keybindings.keybinds.new_tab.bindings.is_empty());
- assert!(keybindings
- .keybinds
- .next_tab
- .bindings
- .iter()
- .any(|binding| binding.label == "prefix+n"));
- }
-
- #[test]
fn handshake_negotiates_terminal_ansi_encoding() {
let (mut client_stream, server_stream, _path) = local_stream_pair("client-handshake-ansi");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
@@ -1321,15 +1443,13 @@ new_tab = "ctrl+notakey"
protocol::write_message(
&mut client_stream,
- &ClientMessage::Hello {
+ &ClientMessage::TerminalHello {
version: PROTOCOL_VERSION,
cols: 100,
rows: 30,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::TerminalAnsi,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::App,
+ pixel_mouse: true,
},
)
.expect("write hello");
@@ -1359,19 +1479,13 @@ new_tab = "ctrl+notakey"
rows,
cell_width_px,
cell_height_px,
- render_encoding,
- keybindings,
- direct_attach_requested,
- direct_graphics,
+ pixel_mouse,
writer,
} => {
assert_eq!(client_id, 42);
assert_eq!((cols, rows), (100, 30));
assert_eq!((cell_width_px, cell_height_px), (8, 16));
- assert_eq!(render_encoding, RenderEncoding::TerminalAnsi);
- assert!(keybindings.is_none());
- assert!(!direct_attach_requested);
- assert!(!direct_graphics);
+ assert!(pixel_mouse);
drop(writer);
}
other => panic!("expected ClientConnected, got {other:?}"),
@@ -1386,59 +1500,66 @@ new_tab = "ctrl+notakey"
}
#[test]
- fn handshake_marks_terminal_attach_launch_mode() {
- let (mut client_stream, server_stream, _path) =
- local_stream_pair("client-handshake-terminal-attach");
+ fn dedicated_client_shell_handshake_uses_surface_viewport() {
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-shell-handshake");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let handshake_quit = should_quit.clone();
let handle = std::thread::spawn(move || {
- handle_client_handshake(server_stream, 42, &server_event_tx, &handshake_quit)
+ handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit)
});
protocol::write_message(
&mut client_stream,
- &ClientMessage::Hello {
+ &ClientMessage::ClientShellHello {
version: PROTOCOL_VERSION,
- cols: 100,
- rows: 30,
cell_width_px: 8,
cell_height_px: 16,
- requested_encoding: RenderEncoding::TerminalAnsi,
- keybindings: ClientKeybindings::Server,
- launch_mode: ClientLaunchMode::TerminalAttach,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 80, rows: 29 },
+ pixel_mouse: true,
+ direct_graphics: true,
+ endpoint_keybindings: true,
+ mouse_capture: true,
},
)
- .expect("write hello");
+ .expect("write shell hello");
let welcome: ServerMessage =
protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome");
- match welcome {
+ assert!(matches!(
+ welcome,
ServerMessage::Welcome {
- version,
- encoding,
- error,
- } => {
- assert_eq!(version, PROTOCOL_VERSION);
- assert_eq!(encoding, RenderEncoding::TerminalAnsi);
- assert_eq!(error, None);
+ encoding: RenderEncoding::SemanticFrame,
+ error: None,
+ ..
}
- other => panic!("expected Welcome, got {other:?}"),
- }
-
+ ));
match server_event_rx
.blocking_recv()
- .expect("client connected event")
+ .expect("client shell connected event")
{
- ServerEvent::ClientConnected {
- direct_attach_requested,
+ ServerEvent::ClientShellConnected {
+ client_id,
+ surface_cols,
+ surface_rows,
+ cell_width_px,
+ cell_height_px,
+ pixel_mouse,
+ direct_graphics,
+ endpoint_keybindings,
+ mouse_capture,
writer,
- ..
} => {
- assert!(direct_attach_requested);
+ assert_eq!(client_id, 43);
+ assert_eq!((surface_cols, surface_rows), (80, 29));
+ assert_eq!((cell_width_px, cell_height_px), (8, 16));
+ assert!(pixel_mouse);
+ assert!(direct_graphics);
+ assert!(endpoint_keybindings);
+ assert!(mouse_capture);
drop(writer);
}
- other => panic!("expected ClientConnected, got {other:?}"),
+ other => panic!("expected ClientShellConnected, got {other:?}"),
}
drop(client_stream);
@@ -1450,6 +1571,85 @@ new_tab = "ctrl+notakey"
}
#[test]
+ fn dedicated_client_shell_handshake_rejects_empty_surface() {
+ let (mut client_stream, server_stream, _path) =
+ local_stream_pair("client-shell-empty-surface");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let handshake_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ handle_client_handshake(server_stream, 43, &server_event_tx, &handshake_quit)
+ });
+
+ protocol::write_message(
+ &mut client_stream,
+ &ClientMessage::ClientShellHello {
+ version: PROTOCOL_VERSION,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 0, rows: 29 },
+ pixel_mouse: false,
+ direct_graphics: false,
+ endpoint_keybindings: false,
+ mouse_capture: false,
+ },
+ )
+ .expect("write empty shell hello");
+
+ let welcome: ServerMessage =
+ protocol::read_message(&mut client_stream, MAX_FRAME_SIZE).expect("read welcome");
+ assert!(matches!(
+ welcome,
+ ServerMessage::Welcome {
+ encoding: RenderEncoding::SemanticFrame,
+ error: Some(error),
+ ..
+ } if error.contains("non-empty pane surface")
+ ));
+ handle
+ .join()
+ .expect("handshake thread join")
+ .expect("handshake thread result");
+ assert!(server_event_rx.try_recv().is_err());
+ }
+
+ #[test]
+ fn client_read_loop_stops_after_detach() {
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-detach");
+ let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
+ let should_quit = Arc::new(AtomicBool::new(false));
+ let read_quit = should_quit.clone();
+ let handle = std::thread::spawn(move || {
+ client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
+ });
+
+ let mut messages = Vec::new();
+ protocol::write_message(&mut messages, &ClientMessage::Detach).unwrap();
+ protocol::write_message(
+ &mut messages,
+ &ClientMessage::ClipboardImage {
+ target: crate::protocol::ClientClipboardImageTarget::DirectTerminal,
+ extension: "png".into(),
+ data: vec![1, 2, 3],
+ },
+ )
+ .unwrap();
+ client_stream
+ .write_all(&messages)
+ .expect("write detach and trailing message");
+
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "detach event"),
+ ServerEvent::ClientDetach { client_id: 7 }
+ ));
+ handle
+ .join()
+ .expect("read thread join")
+ .expect("read thread result");
+ assert!(server_event_rx.try_recv().is_err());
+ }
+
+ #[test]
fn client_read_loop_rejects_oversized_bracketed_paste_without_disconnect() {
let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-oversized");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
@@ -1556,76 +1756,6 @@ new_tab = "ctrl+notakey"
}
#[test]
- fn client_read_loop_disconnects_invalid_pixel_mouse_geometry() {
- let (mut client_stream, server_stream, _path) =
- local_stream_pair("client-read-invalid-pixel-geometry");
- let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
- let should_quit = Arc::new(AtomicBool::new(false));
- let read_quit = should_quit.clone();
- let handle = std::thread::spawn(move || {
- client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
- });
-
- protocol::write_message(
- &mut client_stream,
- &ClientMessage::InputPixels {
- data: b"\x1b[<35;1;1M".to_vec(),
- cols: 0,
- rows: 24,
- width_px: 800,
- height_px: 480,
- },
- )
- .expect("write invalid pixel geometry");
-
- assert!(matches!(
- recv_server_event(&mut server_event_rx, "invalid pixel geometry disconnect"),
- ServerEvent::ClientDisconnected { client_id: 7 }
- ));
- drop(client_stream);
- should_quit.store(true, Ordering::Release);
- handle
- .join()
- .expect("read thread join")
- .expect("read thread result");
- }
-
- #[test]
- fn client_read_loop_disconnects_invalid_pixel_mouse_report() {
- let (mut client_stream, server_stream, _path) =
- local_stream_pair("client-read-invalid-pixel-report");
- let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
- let should_quit = Arc::new(AtomicBool::new(false));
- let read_quit = should_quit.clone();
- let handle = std::thread::spawn(move || {
- client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
- });
-
- protocol::write_message(
- &mut client_stream,
- &ClientMessage::InputPixels {
- data: vec![b'x'; MAX_PIXEL_MOUSE_PAYLOAD + 1],
- cols: 80,
- rows: 24,
- width_px: 800,
- height_px: 480,
- },
- )
- .expect("write invalid pixel report");
-
- assert!(matches!(
- recv_server_event(&mut server_event_rx, "invalid pixel report disconnect"),
- ServerEvent::ClientDisconnected { client_id: 7 }
- ));
- drop(client_stream);
- should_quit.store(true, Ordering::Release);
- handle
- .join()
- .expect("read thread join")
- .expect("read thread result");
- }
-
- #[test]
fn client_read_loop_disconnects_marker_wrapped_invalid_utf8() {
let (mut client_stream, server_stream, _path) =
local_stream_pair("client-read-invalid-utf8-paste");
@@ -1655,51 +1785,42 @@ new_tab = "ctrl+notakey"
}
#[test]
- fn client_read_loop_forwards_input_events() {
- let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-events");
+ fn client_read_loop_uses_authoritative_shell_resize_surface() {
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-resize");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let read_quit = should_quit.clone();
let handle = std::thread::spawn(move || {
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
});
- let events = vec![
- ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Enter,
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Press,
-
- repeat_count: 1,
- generated_text: None,
- source: crate::protocol::ClientKeySource::Synthesized,
- },
- ClientInputEvent::FocusGained,
- ];
protocol::write_message(
&mut client_stream,
- &ClientMessage::InputEvents {
- events: events.clone(),
+ &ClientMessage::ClientShellResize {
+ cell_width_px: 8,
+ cell_height_px: 16,
+ surface_size: crate::protocol::ClientSurfaceSize { cols: 60, rows: 15 },
+ pixel_mouse: true,
},
)
- .expect("write input events");
-
- match server_event_rx
- .blocking_recv()
- .expect("client input events event")
- {
- ServerEvent::ClientInputEvents {
- client_id,
- events: actual,
- } => {
- assert_eq!(client_id, 7);
- assert_eq!(actual, events);
+ .expect("write shell resize");
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "shell resize"),
+ ServerEvent::ClientShellResize {
+ client_id: 7,
+ surface_cols: 60,
+ surface_rows: 15,
+ cell_width_px: 8,
+ cell_height_px: 16,
+ pixel_mouse: true,
}
- other => panic!("expected ClientInputEvents, got {other:?}"),
- }
+ ));
- drop(client_stream);
- should_quit.store(true, Ordering::Release);
+ protocol::write_message(&mut client_stream, &ClientMessage::Detach).expect("write detach");
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "detach event"),
+ ServerEvent::ClientDetach { client_id: 7 }
+ ));
handle
.join()
.expect("read thread join")
@@ -1707,9 +1828,8 @@ new_tab = "ctrl+notakey"
}
#[test]
- fn client_read_loop_rejects_oversized_input_event_batch() {
- let (mut client_stream, server_stream, _path) =
- local_stream_pair("client-read-oversized-events");
+ fn client_read_loop_keeps_single_host_theme_updates_ordered_and_palette_bounded() {
+ let (mut client_stream, server_stream, _path) = local_stream_pair("client-read-host-theme");
let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
let should_quit = Arc::new(AtomicBool::new(false));
let read_quit = should_quit.clone();
@@ -1717,113 +1837,64 @@ new_tab = "ctrl+notakey"
client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
});
+ let colors = (0..=u8::MAX)
+ .map(|index| {
+ (
+ index,
+ crate::protocol::ClientHostColor {
+ r: index,
+ g: 0,
+ b: 0,
+ },
+ )
+ })
+ .collect();
protocol::write_message(
&mut client_stream,
- &ClientMessage::InputEvents {
- events: vec![ClientInputEvent::FocusGained; MAX_INPUT_EVENT_BATCH + 1],
+ &ClientMessage::ClientShellHostTheme {
+ update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors),
},
)
- .expect("write oversized input events");
-
- match server_event_rx
- .blocking_recv()
- .expect("client disconnected event")
- {
- ServerEvent::ClientDisconnected { client_id } => assert_eq!(client_id, 7),
- other => panic!("expected ClientDisconnected, got {other:?}"),
- }
-
- drop(client_stream);
- should_quit.store(true, Ordering::Release);
- handle
- .join()
- .expect("read thread join")
- .expect("read thread result");
- }
-
- #[test]
- fn client_read_loop_rejects_oversized_input_event_paste() {
- let (mut client_stream, server_stream, _path) =
- local_stream_pair("client-read-oversized-paste");
- let (server_event_tx, mut server_event_rx) = mpsc::channel(4);
- let should_quit = Arc::new(AtomicBool::new(false));
- let read_quit = should_quit.clone();
- let handle = std::thread::spawn(move || {
- client_read_loop(server_stream, 7, &server_event_tx, &read_quit)
- });
-
- let maximum = vec![
- ClientInputEvent::Paste {
- text: "x".repeat(MAX_INPUT_PAYLOAD / 2),
- },
- ClientInputEvent::Paste {
- text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2)),
- },
- ];
+ .expect("write bounded palette update");
protocol::write_message(
&mut client_stream,
- &ClientMessage::InputEvents {
- events: maximum.clone(),
- },
- )
- .expect("write maximum-size structured paste");
-
- match recv_server_event(&mut server_event_rx, "maximum-size structured paste") {
- ServerEvent::ClientInputEvents { client_id, events } => {
- assert_eq!(client_id, 7);
- assert_eq!(events, maximum);
- }
- other => panic!("expected maximum-size ClientInputEvents, got {other:?}"),
- }
-
- let oversized = vec![
- ClientInputEvent::FocusGained,
- ClientInputEvent::Paste {
- text: "x".repeat(MAX_INPUT_PAYLOAD / 2),
- },
- ClientInputEvent::Paste {
- text: "y".repeat(MAX_INPUT_PAYLOAD - (MAX_INPUT_PAYLOAD / 2) + 1),
+ &ClientMessage::ClientShellHostTheme {
+ update: crate::protocol::ClientHostThemeUpdate::Appearance(
+ crate::protocol::ClientHostAppearance::Dark,
+ ),
},
- ClientInputEvent::FocusLost,
- ClientInputEvent::Paste {
- text: "tail".to_owned(),
- },
- ];
- protocol::write_message(
- &mut client_stream,
- &ClientMessage::InputEvents { events: oversized },
)
- .expect("write oversized structured paste");
+ .expect("write ordered appearance update");
- match recv_server_event(&mut server_event_rx, "oversized structured paste rejection") {
- ServerEvent::ClientPasteRejected {
- client_id,
- size,
- max,
- } => {
- assert_eq!(client_id, 7);
- assert_eq!(size, MAX_INPUT_PAYLOAD + 5);
- assert_eq!(max, MAX_INPUT_PAYLOAD);
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "bounded palette update"),
+ ServerEvent::ClientShellHostTheme {
+ client_id: 7,
+ update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors),
+ } if colors.len() == 256
+ ));
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "ordered appearance update"),
+ ServerEvent::ClientShellHostTheme {
+ client_id: 7,
+ update: crate::protocol::ClientHostThemeUpdate::Appearance(
+ crate::protocol::ClientHostAppearance::Dark
+ ),
}
- other => panic!("expected ClientPasteRejected, got {other:?}"),
- }
+ ));
- let valid = vec![ClientInputEvent::FocusGained];
+ let colors = vec![(0, crate::protocol::ClientHostColor { r: 0, g: 0, b: 0 },); 257];
protocol::write_message(
&mut client_stream,
- &ClientMessage::InputEvents {
- events: valid.clone(),
+ &ClientMessage::ClientShellHostTheme {
+ update: crate::protocol::ClientHostThemeUpdate::PaletteColors(colors),
},
)
- .expect("write valid structured input after rejection");
-
- match recv_server_event(&mut server_event_rx, "structured input after rejection") {
- ServerEvent::ClientInputEvents { client_id, events } => {
- assert_eq!(client_id, 7);
- assert_eq!(events, valid);
- }
- other => panic!("expected ClientInputEvents after rejection, got {other:?}"),
- }
+ .expect("write oversized palette update");
+ assert!(matches!(
+ recv_server_event(&mut server_event_rx, "oversized palette disconnect"),
+ ServerEvent::ClientDisconnected { client_id: 7 }
+ ));
drop(client_stream);
should_quit.store(true, Ordering::Release);
@@ -1834,40 +1905,18 @@ new_tab = "ctrl+notakey"
}
#[test]
- fn structured_input_limits_charge_grouped_repeats_and_text_payloads() {
- let grouped = ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Char('x'),
+ fn pane_input_limits_charge_scroll_repeats() {
+ let oversized_scroll = ClientPaneInputEvent::Mouse {
+ kind: crate::protocol::ClientMouseKind::ScrollUp,
+ position: crate::protocol::ClientMousePosition::Cell { column: 0, row: 0 },
+ geometry: None,
modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Press,
- repeat_count: (MAX_INPUT_EVENT_BATCH + 1) as u16,
- generated_text: None,
- source: crate::protocol::ClientKeySource::Synthesized,
+ lines: (MAX_INPUT_EVENT_BATCH + 1) as u16,
};
assert_eq!(
- input_event_limit(&[grouped]),
+ pane_input_event_limit(&[oversized_scroll]),
InputEventLimit::TooManyEvents
);
-
- let repeated_text = ClientInputEvent::Key {
- code: crate::protocol::ClientKeyCode::Char('x'),
- modifiers: 0,
- kind: crate::protocol::ClientKeyKind::Press,
- repeat_count: MAX_INPUT_EVENT_BATCH as u16,
- generated_text: Some("x".repeat((MAX_INPUT_PAYLOAD / MAX_INPUT_EVENT_BATCH) + 1)),
- source: crate::protocol::ClientKeySource::Synthesized,
- };
- assert!(matches!(
- input_event_limit(&[repeated_text]),
- InputEventLimit::InputPayloadTooLarge { size } if size > MAX_INPUT_PAYLOAD
- ));
-
- let text = ClientInputEvent::TextCommit("x".repeat(MAX_INPUT_PAYLOAD + 1));
- assert_eq!(
- input_event_limit(&[text]),
- InputEventLimit::InputPayloadTooLarge {
- size: MAX_INPUT_PAYLOAD + 1
- }
- );
}
#[test]