aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/client_transport.rs176
-rw-r--r--src/server/headless.rs139
-rw-r--r--src/server/protocol.rs18
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(),