diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/client_transport.rs | 176 | ||||
| -rw-r--r-- | src/server/headless.rs | 139 | ||||
| -rw-r--r-- | src/server/protocol.rs | 18 |
3 files changed, 284 insertions, 49 deletions
diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index fc6ab0be..0abea899 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -14,8 +14,8 @@ use tokio::sync::mpsc; use tracing::{debug, warn}; use crate::server::protocol::{ - self, ClientMessage, RenderEncoding, ServerMessage, MAX_CLIPBOARD_IMAGE_PAYLOAD, - MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, + self, ClientKeybindings, ClientMessage, RenderEncoding, ServerMessage, + MAX_CLIPBOARD_IMAGE_PAYLOAD, MAX_FRAME_SIZE, MAX_GRAPHICS_FRAME_SIZE, PROTOCOL_VERSION, }; /// Minimum accepted attached client size. @@ -55,6 +55,7 @@ pub(crate) enum ServerEvent { cell_width_px: u32, cell_height_px: u32, render_encoding: RenderEncoding, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, writer: ClientWriter, }, /// A client sent an input message. @@ -96,6 +97,23 @@ 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(), + }))) + } + } +} + /// Handles the client handshake on a blocking thread. /// /// Reads the `Hello` message, validates the version, sends `Welcome`, @@ -130,52 +148,68 @@ pub(crate) fn handle_client_handshake( } }; - let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding) = match hello { - ClientMessage::Hello { - version, - cols, - rows, - cell_width_px, - cell_height_px, - requested_encoding, - } => { - // 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(()); - } - } - - // Clamp size. - let (clamped_cols, clamped_rows) = clamp_terminal_size(cols, rows); - ( - clamped_cols, - clamped_rows, + let (client_cols, client_rows, cell_width_px, cell_height_px, render_encoding, keybindings) = + match hello { + ClientMessage::Hello { + version, + cols, + rows, cell_width_px, cell_height_px, requested_encoding, - ) - } - _ => { - // First message must be Hello. - debug!(client_id, "first message was not Hello, closing"); - let welcome = ServerMessage::Welcome { - version: PROTOCOL_VERSION, - encoding: RenderEncoding::SemanticFrame, - error: Some("expected Hello as first message".to_owned()), - }; - let _ = protocol::write_message(&mut stream, &welcome); - return Ok(()); - } - }; + keybindings, + } => { + // 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(()); + } + } + + 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, + cell_width_px, + cell_height_px, + requested_encoding, + keybindings, + ) + } + _ => { + // First message must be Hello. + debug!(client_id, "first message was not Hello, closing"); + let welcome = ServerMessage::Welcome { + version: PROTOCOL_VERSION, + encoding: RenderEncoding::SemanticFrame, + error: Some("expected Hello as first message".to_owned()), + }; + let _ = protocol::write_message(&mut stream, &welcome); + return Ok(()); + } + }; // Send Welcome. let welcome = ServerMessage::Welcome { @@ -204,6 +238,7 @@ pub(crate) fn handle_client_handshake( cell_width_px, cell_height_px, render_encoding, + keybindings, writer, }); @@ -447,6 +482,54 @@ 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) = UnixStream::pair().expect("socket pair"); let (server_event_tx, mut server_event_rx) = mpsc::channel(4); @@ -465,6 +548,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::TerminalAnsi, + keybindings: ClientKeybindings::Server, }, ) .expect("write hello"); @@ -495,12 +579,14 @@ mod tests { cell_width_px, cell_height_px, render_encoding, + keybindings, 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()); drop(writer); } other => panic!("expected ClientConnected, got {other:?}"), diff --git a/src/server/headless.rs b/src/server/headless.rs index 539892e0..ddfd631c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -209,6 +209,19 @@ fn derive_client_socket_from_api_socket(api_socket_path: &Path) -> PathBuf { parent.join(format!("{stem}-client.sock")) } +fn app_keybindings(app: &app::App) -> crate::config::LiveKeybindConfig { + crate::config::LiveKeybindConfig { + prefix: (app.state.prefix_code, app.state.prefix_mods), + keybinds: app.state.keybinds.clone(), + } +} + +fn apply_keybindings(app: &mut app::App, keybindings: &crate::config::LiveKeybindConfig) { + app.state.prefix_code = keybindings.prefix.0; + app.state.prefix_mods = keybindings.prefix.1; + app.state.keybinds = keybindings.keybinds.clone(); +} + // --------------------------------------------------------------------------- // Connected client state // --------------------------------------------------------------------------- @@ -231,6 +244,8 @@ type RenderTarget = ( struct ClientConnection { /// Whether this connection is the full app client or a direct terminal attach. mode: ClientConnectionMode, + /// Client-local app keybindings. None means use the server's keybindings. + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, /// The client's terminal size (after clamping). terminal_size: (u16, u16), /// Pixel size of one client terminal cell. @@ -270,6 +285,7 @@ impl ClientConnection { ) -> Self { Self::new_with_mode( ClientConnectionMode::App, + None, terminal_size, cell_size, host_terminal_theme, @@ -282,6 +298,7 @@ impl ClientConnection { fn new_with_mode( mode: ClientConnectionMode, + keybindings: Option<Box<crate::config::LiveKeybindConfig>>, terminal_size: (u16, u16), cell_size: crate::kitty_graphics::HostCellSize, host_terminal_theme: crate::terminal_theme::TerminalTheme, @@ -292,6 +309,7 @@ impl ClientConnection { ) -> Self { Self { mode, + keybindings, terminal_size, cell_size, host_terminal_theme, @@ -349,8 +367,10 @@ pub struct HeadlessServer { client_socket_path: PathBuf, clients: HashMap<u64, ClientConnection>, next_client_id: u64, - /// The client currently driving the shared pane runtime size and theme. + /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option<u64>, + /// Server-owned keybindings, restored when foreground clients use server mode. + server_keybindings: crate::config::LiveKeybindConfig, /// Writable direct attach owner per terminal id string. terminal_attach_owners: HashMap<String, u64>, /// Monotonic activity counter used to pick the most recently active client. @@ -390,6 +410,7 @@ impl HeadlessServer { // Channel for server events from client threads. let (server_event_tx, server_event_rx) = mpsc::channel(64); + let server_keybindings = app_keybindings(&app); Ok(Self { app, @@ -398,6 +419,7 @@ impl HeadlessServer { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + server_keybindings, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), @@ -498,7 +520,7 @@ impl HeadlessServer { if self.app.state.request_reload_config { self.app.state.request_reload_config = false; - self.app.reload_config(); + self.reload_server_config(); needs_render = true; } @@ -611,17 +633,27 @@ impl HeadlessServer { let Some(client_id) = self.foreground_client_id else { self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); return; }; let Some(client) = self.clients.get(&client_id) else { self.foreground_client_id = None; self.effective_size = (MIN_COLS, MIN_ROWS); self.app.state.outer_terminal_focus = None; + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); return; }; self.effective_size = client.terminal_size; self.app.state.outer_terminal_focus = client.outer_terminal_focus; + let keybindings = client + .keybindings + .as_deref() + .unwrap_or(&self.server_keybindings) + .clone(); + apply_keybindings(&mut self.app, &keybindings); if client.outer_terminal_focus == Some(true) { self.app.state.mark_active_tab_seen(); } @@ -630,6 +662,15 @@ impl HeadlessServer { } } + fn reload_server_config(&mut self) -> crate::config::ConfigReloadReport { + let server_keybindings = self.server_keybindings.clone(); + apply_keybindings(&mut self.app, &server_keybindings); + let report = self.app.reload_config(); + self.server_keybindings = app_keybindings(&self.app); + self.sync_foreground_client_state(); + report + } + fn foreground_client_outer_focus(&self) -> Option<bool> { let client_id = self.foreground_client_id?; self.clients.get(&client_id)?.outer_terminal_focus @@ -1393,6 +1434,7 @@ impl HeadlessServer { rows, cell_width_px, cell_height_px, + keybindings, writer, render_encoding, } => { @@ -1410,6 +1452,7 @@ impl HeadlessServer { client_id, ClientConnection::new_with_mode( ClientConnectionMode::App, + keybindings, (cols, rows), crate::kitty_graphics::HostCellSize { width_px: cell_width_px, @@ -1675,7 +1718,31 @@ impl HeadlessServer { }; self.sync_foreground_client_state(); - let response = self.app.handle_api_request(msg.request); + let response = if matches!( + &msg.request.method, + api::schema::Method::ServerReloadConfig(_) + ) { + let report = self.reload_server_config(); + serde_json::to_string(&api::schema::SuccessResponse { + id: msg.request.id.clone(), + result: api::schema::ResponseResult::ConfigReload { + status: report.status, + diagnostics: report.diagnostics, + }, + }) + .unwrap_or_else(|err| { + serde_json::to_string(&api::schema::ErrorResponse { + id: String::new(), + error: api::schema::ErrorBody { + code: "serialization_error".into(), + message: err.to_string(), + }, + }) + .unwrap_or_else(|_| "{}".to_string()) + }) + } else { + self.app.handle_api_request(msg.request) + }; let _ = msg.respond_to.send(response); // Forward new toast state only when a client-local delivery mode is selected. @@ -2373,6 +2440,7 @@ mod tests { .set_nonblocking(true) .expect("set listener nonblocking"); let (server_event_tx, server_event_rx) = mpsc::channel(64); + let server_keybindings = app_keybindings(&app); HeadlessServer { app, @@ -2381,6 +2449,7 @@ mod tests { clients: HashMap::new(), next_client_id: 1, foreground_client_id: None, + server_keybindings, terminal_attach_owners: HashMap::new(), next_activity_stamp: 1, effective_size: (MIN_COLS, MIN_ROWS), @@ -2428,6 +2497,68 @@ mod tests { } #[test] + fn foreground_client_applies_client_keybindings() { + let mut server = test_headless_server(); + let local_config: crate::config::Config = toml::from_str( + r#" +[keys] +prefix = "ctrl+a" +new_tab = "prefix+t" +"#, + ) + .unwrap(); + let local_keybindings = local_config.live_keybinds().unwrap(); + let (writer_a, _control_a, _render_a) = test_client_writer(); + let (writer_b, _control_b, _render_b) = test_client_writer(); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 1, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: Some(Box::new(local_keybindings)), + writer: writer_a, + })); + assert_eq!( + server.app.state.prefix_code, + crossterm::event::KeyCode::Char('a') + ); + assert!(server + .app + .state + .keybinds + .new_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+t")); + + assert!(server.handle_server_event(ServerEvent::ClientConnected { + client_id: 2, + cols: 80, + rows: 24, + cell_width_px: 0, + cell_height_px: 0, + render_encoding: RenderEncoding::SemanticFrame, + keybindings: None, + writer: writer_b, + })); + assert_eq!( + server.app.state.prefix_code, + crossterm::event::KeyCode::Char('b') + ); + assert!(server + .app + .state + .keybinds + .new_tab + .bindings + .iter() + .any(|binding| binding.label == "prefix+c")); + } + + #[test] fn terminal_attach_rejects_missing_terminal_and_removes_client() { let mut server = test_headless_server(); let (writer, control_rx, _render_rx) = test_client_writer(); @@ -2439,6 +2570,7 @@ mod tests { cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, writer, })); assert!(server.clients.contains_key(&7)); @@ -2479,6 +2611,7 @@ mod tests { cell_width_px: 0, cell_height_px: 0, render_encoding: RenderEncoding::TerminalAnsi, + keybindings: None, writer, })); assert!( diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 6710bc80..33a5f513 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; // --------------------------------------------------------------------------- /// Current protocol version. Bumped when wire format changes incompatibly. -pub const PROTOCOL_VERSION: u32 = 8; +pub const PROTOCOL_VERSION: u32 = 9; /// Maximum allowed frame payload size (2 MB). Frames larger than this are /// rejected to prevent denial-of-service via oversized length prefixes. @@ -43,6 +43,15 @@ pub enum RenderEncoding { TerminalAnsi, } +/// Keybinding profile requested by an attached app client. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ClientKeybindings { + /// Use the server's own keybinding config. + Server, + /// Use this attached client's normalized local `[keys]` config. + Local { keys_toml: String }, +} + /// Messages sent from the client to the server over the client protocol socket. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ClientMessage { @@ -60,6 +69,8 @@ pub enum ClientMessage { cell_height_px: u32, /// Render encoding requested by the client. requested_encoding: RenderEncoding, + /// Keybinding profile requested by the client. + keybindings: ClientKeybindings, }, /// Raw input bytes read from the client's stdin. @@ -608,6 +619,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap(); let (decoded, _): (ClientMessage, _) = @@ -892,6 +904,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -964,6 +977,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }, 1 => ClientMessage::Input { data: vec![(i % 256) as u8; (i as usize % 50) + 1], @@ -1398,6 +1412,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }; let mut buf = Vec::new(); write_message(&mut buf, &msg).unwrap(); @@ -1431,6 +1446,7 @@ mod tests { cell_width_px: 8, cell_height_px: 16, requested_encoding: RenderEncoding::SemanticFrame, + keybindings: ClientKeybindings::Server, }, ClientMessage::Input { data: b"hello world".to_vec(), |
