diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-13 18:53:37 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-13 18:53:53 +0300 |
| commit | c542fc871743f96f4e665e80f9274a262421d847 (patch) | |
| tree | c3e131449c2fb1d2efc1eb823cfd0123a6e32cd1 | |
| parent | b02a9a81d2316822822c81cbb806012ee278fb68 (diff) | |
fix: gate kitty graphics behind config
| -rw-r--r-- | src/app/mod.rs | 17 | ||||
| -rw-r--r-- | src/client/mod.rs | 145 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 7 | ||||
| -rw-r--r-- | src/kitty_graphics.rs | 94 | ||||
| -rw-r--r-- | src/main.rs | 8 | ||||
| -rw-r--r-- | src/pane.rs | 8 | ||||
| -rw-r--r-- | src/server/client_transport.rs | 8 | ||||
| -rw-r--r-- | src/server/headless.rs | 80 | ||||
| -rw-r--r-- | src/server/protocol.rs | 15 |
9 files changed, 282 insertions, 100 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 84b8e72f..8ef9d8f6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -501,17 +501,20 @@ impl App { if needs_render && self.can_render_now(now) { self.render_dirty.swap(false, Ordering::AcqRel); let _sync_output = SyncOutputGuard::begin()?; + let kitty_graphics_enabled = self.state.kitty_graphics_enabled; let mut cell_size = crate::kitty_graphics::HostCellSize::default(); terminal.draw(|frame| { let area = frame.area(); - cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area); - crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); + if kitty_graphics_enabled { + cell_size = crate::kitty_graphics::HostCellSize::from_terminal(area); + crate::ui::compute_view_with_cell_size(&mut self.state, area, cell_size); + } else { + crate::ui::compute_view(&mut self.state, area); + } crate::ui::render(&self.state, frame); })?; - if self.state.kitty_graphics_enabled { + if kitty_graphics_enabled { crate::kitty_graphics::paint_local_pane_graphics(&self.state, cell_size)?; - } else { - crate::kitty_graphics::clear_all_host_graphics()?; } self.last_render_at = Some(now); needs_render = false; @@ -695,8 +698,12 @@ impl App { } if !invalid_section("advanced") { + let was_kitty_graphics_enabled = self.state.kitty_graphics_enabled; self.state.kitty_graphics_enabled = config.advanced.kitty_graphics; crate::kitty_graphics::set_enabled(config.advanced.kitty_graphics); + if was_kitty_graphics_enabled && !config.advanced.kitty_graphics { + let _ = crate::kitty_graphics::clear_all_host_graphics(); + } self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes; } diff --git a/src/client/mod.rs b/src/client/mod.rs index a29a40f6..9679e95c 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -15,10 +15,11 @@ pub(crate) mod blit; mod input; +use std::collections::HashSet; use std::io::{self, Write as _}; use std::os::unix::net::UnixStream; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use crossterm::event::{ @@ -35,8 +36,7 @@ use crate::server::protocol::{ PROTOCOL_VERSION, }; -const DELETE_ALL_KITTY_GRAPHICS: &[u8] = b"\x1b_Ga=d,d=A,q=2;\x1b\\"; -static RECEIVED_KITTY_GRAPHICS: AtomicBool = AtomicBool::new(false); +static RECEIVED_KITTY_GRAPHICS_IDS: OnceLock<Mutex<HashSet<u32>>> = OnceLock::new(); // --------------------------------------------------------------------------- // Client state @@ -50,6 +50,8 @@ struct ClientState { reported_size: (u16, u16), /// Client-local sound playback config, refreshed on server request. sound_config: crate::config::SoundConfig, + /// Whether this client may write Kitty graphics bytes to its host terminal. + kitty_graphics_enabled: bool, } // --------------------------------------------------------------------------- @@ -175,9 +177,7 @@ fn write_terminal_restore_postlude(writer: &mut impl io::Write) -> io::Result<() } fn restore_terminal_state(in_tmux: bool) { - if RECEIVED_KITTY_GRAPHICS.swap(false, Ordering::AcqRel) { - let _ = io::stdout().write_all(DELETE_ALL_KITTY_GRAPHICS); - } + let _ = clear_received_kitty_graphics(&mut io::stdout()); // Reset modifyOtherKeys if we enabled it. if in_tmux { @@ -295,6 +295,7 @@ pub fn run_client() -> io::Result<()> { let loaded_config = crate::config::Config::load(); let sound_config = loaded_config.config.ui.sound; + let kitty_graphics_enabled = loaded_config.config.advanced.kitty_graphics; let socket_path = client_socket_path(); crate::logging::startup("client"); @@ -312,7 +313,8 @@ pub fn run_client() -> io::Result<()> { }; // Get the terminal geometry before handshake (before raw mode). - let (cols, rows, cell_width_px, cell_height_px) = current_terminal_geometry(); + let (cols, rows, cell_width_px, cell_height_px) = + current_terminal_geometry(kitty_graphics_enabled); let requested_encoding = requested_render_encoding(); @@ -369,6 +371,7 @@ pub fn run_client() -> io::Result<()> { rows, should_quit, sound_config, + kitty_graphics_enabled, negotiated_encoding, ) .await @@ -412,12 +415,14 @@ async fn run_client_loop( rows: u16, should_quit: Arc<AtomicBool>, sound_config: crate::config::SoundConfig, + kitty_graphics_enabled: bool, negotiated_encoding: RenderEncoding, ) -> Result<(), ClientError> { let mut state = ClientState { blit_encoder: blit::BlitEncoder::new(), reported_size: (cols, rows), sound_config, + kitty_graphics_enabled, }; debug!(?negotiated_encoding, "client render encoding active"); @@ -437,7 +442,7 @@ async fn run_client_loop( let resize_quit = should_quit.clone(); let resize_tx = event_tx.clone(); std::thread::spawn(move || { - resize_poll_loop(resize_tx, cols, rows, &resize_quit); + resize_poll_loop(resize_tx, cols, rows, kitty_graphics_enabled, &resize_quit); }); // Spawn the server reader thread (blocking reads from the socket). @@ -486,27 +491,31 @@ async fn run_client_loop( ServerMessage::Frame(frame_data) => { let encoded = state.blit_encoder.encode(&frame_data, false); let mut stdout = io::stdout(); - let _ = write_encoded_frame_with_graphics( - &mut stdout, - &encoded.bytes, - &frame_data.graphics, - ); + let graphics = if state.kitty_graphics_enabled { + frame_data.graphics.as_slice() + } else { + &[] + }; + let _ = + write_encoded_frame_with_graphics(&mut stdout, &encoded.bytes, graphics); let _ = stdout.flush(); state.blit_encoder.commit(frame_data, encoded); } ServerMessage::Terminal(frame) => { - if contains_kitty_graphics_bytes(&frame.bytes) { - RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); + if state.kitty_graphics_enabled && contains_kitty_graphics_bytes(&frame.bytes) { + record_received_kitty_graphics(&frame.bytes); } let mut stdout = io::stdout(); let _ = stdout.write_all(&frame.bytes); let _ = stdout.flush(); } ServerMessage::Graphics { bytes } => { - RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); - let mut stdout = io::stdout(); - let _ = stdout.write_all(&bytes); - let _ = stdout.flush(); + if state.kitty_graphics_enabled { + record_received_kitty_graphics(&bytes); + let mut stdout = io::stdout(); + let _ = stdout.write_all(&bytes); + let _ = stdout.flush(); + } } ServerMessage::ServerShutdown { reason } => { return Err(ClientError::ServerShutdown { reason }); @@ -708,7 +717,7 @@ fn write_encoded_frame_with_graphics( return writer.write_all(encoded); } - RECEIVED_KITTY_GRAPHICS.store(true, Ordering::Release); + record_received_kitty_graphics(graphics); if let Some(sync_end) = rfind_subslice(encoded, SYNC_OUTPUT_END) { writer.write_all(&encoded[..sync_end])?; @@ -727,6 +736,73 @@ fn contains_kitty_graphics_bytes(bytes: &[u8]) -> bool { bytes.windows(3).any(|window| window == b"\x1b_G") } +fn record_received_kitty_graphics(bytes: &[u8]) { + let ids = kitty_graphics_image_ids(bytes); + if ids.is_empty() { + return; + } + let set = RECEIVED_KITTY_GRAPHICS_IDS.get_or_init(|| Mutex::new(HashSet::new())); + if let Ok(mut set) = set.lock() { + set.extend(ids); + } +} + +fn clear_received_kitty_graphics(mut writer: impl io::Write) -> io::Result<()> { + let Some(set) = RECEIVED_KITTY_GRAPHICS_IDS.get() else { + return Ok(()); + }; + let Ok(mut set) = set.lock() else { + return Ok(()); + }; + for id in set.drain() { + write!(writer, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\")?; + } + writer.flush() +} + +fn kitty_graphics_image_ids(bytes: &[u8]) -> Vec<u32> { + let mut ids = Vec::new(); + let mut index = 0usize; + while let Some(start) = find_subslice(&bytes[index..], b"\x1b_G") { + let command_start = index + start + 3; + let Some(end) = find_subslice(&bytes[command_start..], b"\x1b\\") else { + break; + }; + let command = &bytes[command_start..command_start + end]; + if let Some(id) = kitty_graphics_command_image_id(command) { + ids.push(id); + } + index = command_start + end + 2; + } + ids +} + +fn kitty_graphics_command_image_id(command: &[u8]) -> Option<u32> { + let header_end = command + .iter() + .position(|byte| *byte == b';') + .unwrap_or(command.len()); + for part in command[..header_end].split(|byte| *byte == b',') { + let Some(value) = part.strip_prefix(b"i=") else { + continue; + }; + let text = std::str::from_utf8(value).ok()?; + if let Ok(id) = text.parse::<u32>() { + return Some(id); + } + } + None +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { if needle.is_empty() || needle.len() > haystack.len() { return None; @@ -741,8 +817,11 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> { // Resize polling // --------------------------------------------------------------------------- -fn current_terminal_geometry() -> (u16, u16, u32, u32) { +fn current_terminal_geometry(kitty_graphics_enabled: bool) -> (u16, u16, u32, u32) { let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + if !kitty_graphics_enabled { + return (cols, rows, 0, 0); + } let Ok(size) = crossterm::terminal::window_size() else { return (cols, rows, 8, 16); }; @@ -762,9 +841,11 @@ fn resize_poll_loop( resize_tx: tokio::sync::mpsc::Sender<ClientLoopEvent>, initial_cols: u16, initial_rows: u16, + kitty_graphics_enabled: bool, should_quit: &Arc<AtomicBool>, ) { - let (_, _, initial_cell_width, initial_cell_height) = current_terminal_geometry(); + let (_, _, initial_cell_width, initial_cell_height) = + current_terminal_geometry(kitty_graphics_enabled); let mut last_size = ( initial_cols, initial_rows, @@ -773,7 +854,7 @@ fn resize_poll_loop( ); while !should_quit.load(Ordering::Acquire) { std::thread::sleep(Duration::from_millis(100)); - let new_size = current_terminal_geometry(); + let new_size = current_terminal_geometry(kitty_graphics_enabled); if new_size != last_size { last_size = new_size; if resize_tx @@ -842,6 +923,24 @@ mod tests { } #[test] + fn kitty_graphics_image_id_parser_tracks_herdr_ids_only() { + let ids = kitty_graphics_image_ids( + b"text\x1b_Ga=t,t=d,f=32,s=1,v=1,i=10023,q=2;AAAA\x1b\\\x1b_Ga=p,i=10023,p=7;\x1b\\", + ); + assert_eq!(ids, vec![10023, 10023]); + } + + #[test] + fn kitty_graphics_cleanup_deletes_tracked_images_not_all_images() { + record_received_kitty_graphics(b"\x1b_Ga=t,i=123,q=2;AAAA\x1b\\"); + let mut output = Vec::new(); + clear_received_kitty_graphics(&mut output).unwrap(); + let text = String::from_utf8(output).unwrap(); + assert!(text.contains("a=d,d=I,i=123")); + assert!(!text.contains("d=A")); + } + + #[test] fn write_host_terminal_theme_query_emits_osc_queries() { let mut output = Vec::new(); write_host_terminal_theme_query(&mut output).unwrap(); diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 1652621a..b69dfdb7 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -1188,12 +1188,7 @@ fn kitty_image_fingerprint( } let data = unsafe { slice::from_raw_parts(ptr_out, len) }; - let prefix_len = data.len().min(256); - data[..prefix_len].hash(&mut hasher); - if data.len() > prefix_len { - let suffix_len = data.len().min(256); - data[data.len() - suffix_len..].hash(&mut hasher); - } + data.hash(&mut hasher); hasher.finish() } diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 43cbacbd..329e67d6 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -16,7 +16,6 @@ use crate::layout::PaneId; const KITTY_CHUNK_BYTES: usize = 3072; const HOST_IMAGE_ID_BASE: u32 = 10_000; -const STALE_PLACEMENT_GRACE_FRAMES: u8 = 1; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct HostCellSize { @@ -86,12 +85,6 @@ struct ImageSignature { data_fingerprint: u64, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct HostPlacementState { - signature: PlacementSignature, - missed_frames: u8, -} - #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] struct PlacementSignature { x: u16, @@ -104,6 +97,7 @@ struct PlacementSignature { source_height: u32, x_offset: u32, y_offset: u32, + z: i32, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -123,7 +117,7 @@ struct ClippedPlacement { #[derive(Debug, Default, Clone)] pub(crate) struct HostGraphicsCache { images: HashMap<u32, ImageSignature>, - placements: HashMap<(u32, u32), HostPlacementState>, + placements: HashMap<(u32, u32), PlacementSignature>, view: Option<HostViewKey>, } @@ -216,9 +210,9 @@ pub(crate) fn encode_local_pane_graphics( fn encode_graphics_update( bytes: &mut Vec<u8>, placements: &[HostPlacement], - view_changed: bool, + _view_changed: bool, host_images: &mut HashMap<u32, ImageSignature>, - host_placements: &mut HashMap<(u32, u32), HostPlacementState>, + host_placements: &mut HashMap<(u32, u32), PlacementSignature>, ) { let mut current_placements = HashSet::new(); for placement in placements { @@ -240,7 +234,7 @@ fn encode_graphics_update( let host_id = host_image_id(placement.pane_id, &placement.placement); let host_placement_id = host_placement_id(placement.pane_id, &placement.placement); let image_signature = image_signature(placement, format_code); - let placement_signature = placement_signature(clipped); + let placement_signature = placement_signature(clipped, placement.placement.z); let placement_key = (host_id, host_placement_id); current_placements.insert(placement_key); @@ -256,19 +250,21 @@ fn encode_graphics_update( true } }); - encode_upload_image(bytes, placement, format_code, host_id); + if !encode_upload_image(bytes, placement, format_code, host_id) { + continue; + } host_images.insert(host_id, image_signature); } None => { - encode_upload_image(bytes, placement, format_code, host_id); + if !encode_upload_image(bytes, placement, format_code, host_id) { + continue; + } host_images.insert(host_id, image_signature); } } match host_placements.get_mut(&placement_key) { - Some(existing) if existing.signature == placement_signature => { - existing.missed_frames = 0; - } + Some(existing) if *existing == placement_signature => {} Some(existing) => { encode_display_placement( bytes, @@ -277,8 +273,7 @@ fn encode_graphics_update( host_placement_id, placement.placement.z, ); - existing.signature = placement_signature; - existing.missed_frames = 0; + *existing = placement_signature; } None => { encode_display_placement( @@ -288,26 +283,16 @@ fn encode_graphics_update( host_placement_id, placement.placement.z, ); - host_placements.insert( - placement_key, - HostPlacementState { - signature: placement_signature, - missed_frames: 0, - }, - ); + host_placements.insert(placement_key, placement_signature); } } } let mut stale_placements = Vec::new(); - for (key, state) in &mut *host_placements { + for key in host_placements.keys() { if current_placements.contains(key) { continue; } - if !view_changed && state.missed_frames < STALE_PLACEMENT_GRACE_FRAMES { - state.missed_frames += 1; - continue; - } stale_placements.push(*key); } for (host_id, host_placement_id) in stale_placements { @@ -460,9 +445,9 @@ fn encode_upload_image( placement: &HostPlacement, format_code: u32, host_id: u32, -) { +) -> bool { if placement.placement.data.is_empty() { - return; + return false; } let control = format!( @@ -470,6 +455,7 @@ fn encode_upload_image( placement.placement.image_width, placement.placement.image_height, ); encode_kitty_data(out, &control, &placement.placement.data); + true } fn encode_display_placement( @@ -677,7 +663,7 @@ fn image_signature_from_descriptor( } } -fn placement_signature(clipped: ClippedPlacement) -> PlacementSignature { +fn placement_signature(clipped: ClippedPlacement, z: i32) -> PlacementSignature { PlacementSignature { x: clipped.x, y: clipped.y, @@ -689,6 +675,7 @@ fn placement_signature(clipped: ClippedPlacement) -> PlacementSignature { source_height: clipped.source_height, x_offset: clipped.x_offset, y_offset: clipped.y_offset, + z, } } @@ -807,6 +794,20 @@ mod tests { encode_graphics_update(&mut bytes, &[same], false, &mut images, &mut placements); assert!(bytes.is_empty()); + let mut z_changed = test_placement(0, 0); + z_changed.placement.z = 1; + encode_graphics_update( + &mut bytes, + &[z_changed], + false, + &mut images, + &mut placements, + ); + let z_changed_bytes = String::from_utf8_lossy(&bytes); + assert!(!z_changed_bytes.contains("a=t")); + assert!(z_changed_bytes.contains("a=p")); + + bytes.clear(); let moved = test_placement(0, 1); encode_graphics_update(&mut bytes, &[moved], false, &mut images, &mut placements); let moved_bytes = String::from_utf8_lossy(&bytes); @@ -815,11 +816,12 @@ mod tests { } #[test] - fn stale_placement_grace_deletes_placement_not_image() { + fn empty_image_data_does_not_mark_image_uploaded() { let mut images = HashMap::new(); let mut placements = HashMap::new(); let mut bytes = Vec::new(); - let placement = test_placement(0, 0); + let mut placement = test_placement(0, 0); + placement.placement.data.clear(); encode_graphics_update( &mut bytes, @@ -828,13 +830,29 @@ mod tests { &mut images, &mut placements, ); - assert_eq!(placements.len(), 1); - bytes.clear(); - encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements); assert!(bytes.is_empty()); + assert!(images.is_empty()); + assert!(placements.is_empty()); + } + + #[test] + fn stale_placement_deletes_placement_not_image_immediately() { + let mut images = HashMap::new(); + let mut placements = HashMap::new(); + let mut bytes = Vec::new(); + let placement = test_placement(0, 0); + + encode_graphics_update( + &mut bytes, + &[placement], + false, + &mut images, + &mut placements, + ); assert_eq!(placements.len(), 1); + bytes.clear(); encode_graphics_update(&mut bytes, &[], false, &mut images, &mut placements); let delete = String::from_utf8_lossy(&bytes); assert!(delete.contains("a=d,d=i")); diff --git a/src/main.rs b/src/main.rs index d2dfedd9..7fcf624f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -427,7 +427,9 @@ fn main() -> io::Result<()> { if panic_in_tmux { let _ = std::io::Write::write_all(&mut io::stdout(), b"\x1b[>4;0m"); } - let _ = crate::kitty_graphics::clear_all_host_graphics(); + if crate::kitty_graphics::is_enabled() { + let _ = crate::kitty_graphics::clear_all_host_graphics(); + } let _ = execute!( io::stdout(), PopKeyboardEnhancementFlags, @@ -490,7 +492,9 @@ fn main() -> io::Result<()> { std::io::stdout().flush()?; } - crate::kitty_graphics::clear_all_host_graphics()?; + if crate::kitty_graphics::is_enabled() { + crate::kitty_graphics::clear_all_host_graphics()?; + } execute!( io::stdout(), PopKeyboardEnhancementFlags, diff --git a/src/pane.rs b/src/pane.rs index 8a89fa54..d9b63fb9 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -357,9 +357,11 @@ impl PaneRuntime { let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes) .map_err(|e| std::io::Error::other(e.to_string()))?; - terminal - .enable_kitty_graphics() - .map_err(|e| std::io::Error::other(e.to_string()))?; + if crate::kitty_graphics::is_enabled() { + terminal + .enable_kitty_graphics() + .map_err(|e| std::io::Error::other(e.to_string()))?; + } let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?; pane_terminal.apply_host_terminal_theme(host_terminal_theme); let terminal = Arc::new(PaneTerminal::new(pane_terminal)); diff --git a/src/server/client_transport.rs b/src/server/client_transport.rs index a74bf7f6..348d00c7 100644 --- a/src/server/client_transport.rs +++ b/src/server/client_transport.rs @@ -146,8 +146,8 @@ pub(crate) fn handle_client_handshake( ( clamped_cols, clamped_rows, - cell_width_px.max(1), - cell_height_px.max(1), + cell_width_px, + cell_height_px, requested_encoding, ) } @@ -349,8 +349,8 @@ fn client_read_loop( client_id, cols: clamped_cols, rows: clamped_rows, - cell_width_px: cell_width_px.max(1), - cell_height_px: cell_height_px.max(1), + cell_width_px, + cell_height_px, } } ClientMessage::Detach => ServerEvent::ClientDetach { client_id }, diff --git a/src/server/headless.rs b/src/server/headless.rs index 3e978d5a..f490ec45 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -505,7 +505,11 @@ impl HeadlessServer { }; let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); - crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size); + if self.app.state.kitty_graphics_enabled && client.cell_size.is_known() { + crate::ui::compute_view_with_cell_size(&mut self.app.state, area, client.cell_size); + } else { + crate::ui::compute_view(&mut self.app.state, area); + } // Shared runtime size changes affect pane wrapping and foreground-driven // rendering semantics. Force one fresh frame to every remaining client @@ -1458,12 +1462,21 @@ impl HeadlessServer { let mut broken_clients: Vec<u64> = Vec::new(); for (client_id, (cols, rows), cell_size, is_foreground) in render_targets { let area = Rect::new(0, 0, cols, rows); - let (buffer, cursor) = crate::server::render_stream::render_virtual_with_cell_size( - &mut self.app.state, - area, - is_foreground, - cell_size, - ); + let (buffer, cursor) = if self.app.state.kitty_graphics_enabled && cell_size.is_known() + { + crate::server::render_stream::render_virtual_with_cell_size( + &mut self.app.state, + area, + is_foreground, + cell_size, + ) + } else { + crate::server::render_stream::render_virtual( + &mut self.app.state, + area, + is_foreground, + ) + }; let hyperlinks = crate::server::render_stream::visible_hyperlinks(&self.app.state); let mut frame = FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks); @@ -1472,7 +1485,7 @@ impl HeadlessServer { continue; }; let mut next_graphics_cache = client.graphics_cache.clone(); - if self.app.state.kitty_graphics_enabled { + if self.app.state.kitty_graphics_enabled && cell_size.is_known() { frame.graphics = crate::kitty_graphics::encode_local_pane_graphics( &self.app.state, cell_size, @@ -1486,13 +1499,54 @@ impl HeadlessServer { continue; }; - let Some(prepared) = client.render_state.prepare_frame(&frame) else { + let mut commit_graphics_cache = true; + if frame.graphics.len() > MAX_FRAME_SIZE { + warn!( + client_id, + graphics_bytes = frame.graphics.len(), + max = MAX_FRAME_SIZE, + "dropping oversized graphics payload for client frame" + ); + frame.graphics.clear(); + commit_graphics_cache = false; + } + + let Some(mut prepared) = client.render_state.prepare_frame(&frame) else { client.render_pending = false; continue; }; + let mut frame_to_commit = frame.clone(); let serialized = match Self::frame_server_message(prepared.message()) { Ok(framed) => framed, + Err(protocol::FramingError::Oversized { claimed, max }) + if !frame.graphics.is_empty() => + { + warn!( + client_id, + claimed, max, "dropping graphics from oversized frame for client" + ); + let mut text_only_frame = frame.clone(); + text_only_frame.graphics.clear(); + let Some(text_only_prepared) = + client.render_state.prepare_frame(&text_only_frame) + else { + client.render_pending = false; + continue; + }; + let framed = match Self::frame_server_message(text_only_prepared.message()) { + Ok(framed) => framed, + Err(err) => { + warn!(client_id, err = %err, "failed to serialize text-only frame for client"); + broken_clients.push(client_id); + continue; + } + }; + prepared = text_only_prepared; + frame_to_commit = text_only_frame; + commit_graphics_cache = false; + framed + } Err(protocol::FramingError::Oversized { claimed, max }) => { warn!( client_id, @@ -1510,8 +1564,12 @@ impl HeadlessServer { match writer.render.try_send(serialized) { Ok(()) => { client.render_pending = false; - client.graphics_cache = next_graphics_cache; - client.render_state.commit_sent_frame(frame, prepared); + if commit_graphics_cache { + client.graphics_cache = next_graphics_cache; + } + client + .render_state + .commit_sent_frame(frame_to_commit, prepared); } Err(std::sync::mpsc::TrySendError::Full(_)) => { client.render_pending = true; diff --git a/src/server/protocol.rs b/src/server/protocol.rs index 743817ac..8748c41c 100644 --- a/src/server/protocol.rs +++ b/src/server/protocol.rs @@ -15,10 +15,9 @@ use serde::{Deserialize, Serialize}; /// Current protocol version. Bumped when wire format changes incompatibly. pub const PROTOCOL_VERSION: u32 = 5; -/// Maximum allowed frame payload size (32 MB). Frames larger than this are -/// rejected to prevent denial-of-service via oversized length prefixes while -/// leaving room for first-frame Kitty graphics image uploads. -pub const MAX_FRAME_SIZE: usize = 32 * 1024 * 1024; +/// Maximum allowed frame payload size (2 MB). Frames larger than this are +/// rejected to prevent denial-of-service via oversized length prefixes. +pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; /// Length of the u32 little-endian length prefix in bytes. const LENGTH_PREFIX_BYTES: usize = 4; @@ -47,9 +46,9 @@ pub enum ClientMessage { cols: u16, /// Terminal height in rows. rows: u16, - /// Width of a terminal cell in physical pixels. + /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels. + /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, /// Render encoding requested by the client. requested_encoding: RenderEncoding, @@ -67,9 +66,9 @@ pub enum ClientMessage { cols: u16, /// New terminal height in rows. rows: u16, - /// Width of a terminal cell in physical pixels. + /// Width of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_width_px: u32, - /// Height of a terminal cell in physical pixels. + /// Height of a terminal cell in physical pixels, or 0 when client-side Kitty graphics are disabled. cell_height_px: u32, }, |
