diff options
Diffstat (limited to 'src/protocol')
| -rw-r--r-- | src/protocol/endpoint.rs | 8 | ||||
| -rw-r--r-- | src/protocol/mod.rs | 1 | ||||
| -rw-r--r-- | src/protocol/surface_reuse.rs | 127 | ||||
| -rw-r--r-- | src/protocol/wire.rs | 17 |
4 files changed, 152 insertions, 1 deletions
diff --git a/src/protocol/endpoint.rs b/src/protocol/endpoint.rs index 640e594e..2f7c8338 100644 --- a/src/protocol/endpoint.rs +++ b/src/protocol/endpoint.rs @@ -46,6 +46,9 @@ pub struct EndpointClientHello { pub mouse_capture: bool, #[serde(default = "default_true")] pub surface_active: bool, + /// Accept the optional cell-retaining surface encoding on this connection. + #[serde(default)] + pub surface_reuse: bool, #[serde(default)] pub snapshot_codecs: Vec<String>, #[serde(default)] @@ -139,6 +142,7 @@ impl EndpointServerWelcome { blob_codec: BLOB_CODEC_V1.into(), methods, capabilities: vec![ + super::surface_reuse::CAPABILITY.into(), SURFACE_INTEREST_CAPABILITY.into(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(), HEALTH_CHECK_CAPABILITY.into(), @@ -181,6 +185,7 @@ mod tests { endpoint_keybindings: false, mouse_capture: true, surface_active: true, + surface_reuse: false, snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()], surface_codecs: vec![SURFACE_CODEC_V1.into()], input_codecs: vec![INPUT_CODEC_V1.into()], @@ -327,8 +332,10 @@ mod tests { fn legacy_hello_defaults_to_an_active_surface() { let mut value = serde_json::to_value(hello()).unwrap(); value.as_object_mut().unwrap().remove("surface_active"); + value.as_object_mut().unwrap().remove("surface_reuse"); let decoded: EndpointClientHello = serde_json::from_value(value).unwrap(); assert!(decoded.surface_active); + assert!(!decoded.surface_reuse); } #[test] @@ -337,6 +344,7 @@ mod tests { assert_eq!( welcome.capabilities, vec![ + super::super::surface_reuse::CAPABILITY.to_string(), SURFACE_INTEREST_CAPABILITY.to_string(), PRESENTATION_EFFECTS_FENCE_CAPABILITY.to_string(), HEALTH_CHECK_CAPABILITY.to_string(), diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 7ee6e8de..4a8766b6 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -2,6 +2,7 @@ pub mod endpoint; pub(crate) mod render_ansi; +pub(crate) mod surface_reuse; mod wire; pub use wire::*; diff --git a/src/protocol/surface_reuse.rs b/src/protocol/surface_reuse.rs new file mode 100644 index 00000000..bba85824 --- /dev/null +++ b/src/protocol/surface_reuse.rs @@ -0,0 +1,127 @@ +//! Optional endpoint encoding that retains unchanged terminal cells across projections. + +use super::{CellData, PaneSurfaceFrame, ServerMessage}; +use serde::{Deserialize, Serialize}; + +pub(crate) const CAPABILITY: &str = "surface_reuse"; +pub(crate) const MESSAGE_KIND: &str = "endpoint.surface-reuse.v1"; + +#[derive(Serialize, Deserialize)] +struct SurfaceReuse<S> { + base_surface_revision: u64, + surface: S, +} + +pub(crate) fn message( + base_surface_revision: u64, + surface: &mut PaneSurfaceFrame, +) -> serde_json::Result<Option<ServerMessage>> { + let cells = std::mem::take(&mut surface.frame.cells); + let data = serde_json::to_string(&SurfaceReuse { + base_surface_revision, + surface: &*surface, + }); + surface.frame.cells = cells; + let message = ServerMessage::EndpointControl { + kind: MESSAGE_KIND.into(), + data: data?, + }; + // JSON can expand non-cell data (for example escaped hyperlink URLs). A + // failed compact encoding must fall back, not strand a newer snapshot. + let size = bincode::serde::encode_into_std_write( + &message, + &mut std::io::sink(), + bincode::config::standard(), + ); + match size { + Ok(size) => Ok((size <= super::MAX_FRAME_SIZE).then_some(message)), + Err(error) => { + tracing::warn!(%error, "failed to size surface reuse"); + Ok(None) + } + } +} + +#[derive(Default)] +struct CellBaseline { + boot_id: String, + projection_revision: u64, + surface_revision: u64, + width: u16, + height: u16, + cells: Vec<CellData>, +} + +/// Connection-local decoding happens before activation and presentation filtering, so +/// switching endpoints cannot discard a baseline needed by the next wire message. +#[derive(Default)] +pub(crate) struct Decoder { + baseline: Option<CellBaseline>, +} + +impl Decoder { + pub(crate) fn decode(&mut self, message: ServerMessage) -> Result<ServerMessage, String> { + let message = match message { + ServerMessage::EndpointControl { kind, data } if kind == MESSAGE_KIND => { + let reuse: SurfaceReuse<PaneSurfaceFrame> = serde_json::from_str(&data) + .map_err(|error| format!("invalid surface reuse: {error}"))?; + let Some(base) = &mut self.baseline else { + return Err("surface reuse without a baseline".into()); + }; + let mut surface = reuse.surface; + if base.boot_id != surface.boot_id + || base.surface_revision != reuse.base_surface_revision + || surface.surface_revision != base.surface_revision.saturating_add(1) + || base.width != surface.frame.width + || base.height != surface.frame.height + || !surface.frame.cells.is_empty() + { + return Err("surface reuse does not match its baseline".into()); + } + surface.frame.cells.clone_from(&base.cells); + base.projection_revision = surface.projection_revision; + base.surface_revision = surface.surface_revision; + return Ok(ServerMessage::PaneSurface(surface)); + } + message => message, + }; + match &message { + ServerMessage::PaneSurface(surface) => { + let base = self.baseline.get_or_insert_with(CellBaseline::default); + base.boot_id.clone_from(&surface.boot_id); + base.projection_revision = surface.projection_revision; + base.surface_revision = surface.surface_revision; + base.width = surface.frame.width; + base.height = surface.frame.height; + base.cells.clone_from(&surface.frame.cells); + } + ServerMessage::PaneSurfacePatch(patch) => { + if let Some(base) = &mut self.baseline { + if patch.boot_id != base.boot_id + || patch.projection_revision != base.projection_revision + || patch.base_surface_revision != base.surface_revision + || patch.surface_revision != base.surface_revision.saturating_add(1) + { + self.baseline = None; + } else { + for row in &patch.rows { + let start = + usize::from(row.y) * usize::from(base.width) + usize::from(row.x); + let end = start.saturating_add(row.cells.len()); + if row.y >= base.height + || usize::from(row.x) + row.cells.len() > usize::from(base.width) + || end > base.cells.len() + { + return Err("surface patch exceeds the cell baseline".into()); + } + base.cells[start..end].clone_from_slice(&row.cells); + } + base.surface_revision = patch.surface_revision; + } + } + } + _ => {} + } + Ok(message) + } +} diff --git a/src/protocol/wire.rs b/src/protocol/wire.rs index 052c73a3..a29c6ca6 100644 --- a/src/protocol/wire.rs +++ b/src/protocol/wire.rs @@ -699,7 +699,7 @@ pub enum AttachScrollSource { /// A single cell in a rendered frame, serialized independently from ratatui's /// `Cell` type to keep the wire protocol stable. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CellData { /// Grapheme cluster displayed in this cell (usually 1–2 chars). pub symbol: String, @@ -715,6 +715,21 @@ pub struct CellData { pub hyperlink: Option<u32>, } +impl Clone for CellData { + fn clone(&self) -> Self { + Self { + symbol: self.symbol.clone(), + ..*self + } + } + + fn clone_from(&mut self, source: &Self) { + let mut symbol = std::mem::take(&mut self.symbol); + symbol.clone_from(&source.symbol); + *self = Self { symbol, ..*source }; + } +} + impl CellData { pub(crate) fn from_ratatui_cell(cell: &ratatui::buffer::Cell) -> Self { Self { |
