aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-09 02:04:39 +0300
committerGitHub <noreply@github.com>2026-08-09 02:04:39 +0300
commit1777e9bba32b953ed1ad203b4a16d01105539000 (patch)
tree22096973812c9baba822453d8470bdcfe64fc2a6 /src/app/api
parent36a8d36a7258e08b40c01bf521b1f4006525f143 (diff)
feat(graphics): add direct pane frame streaming (#2523)
* feat(graphics): add direct pane frame streaming * feat(graphics): expose pane visibility * test(graphics): preserve legacy terminal image output * fix(graphics): harden streaming lifecycle * fix(input): preserve mouse releases outside panes
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/pane_graphics.rs1512
-rw-r--r--src/app/api/plugins/mod.rs4
2 files changed, 1019 insertions, 497 deletions
diff --git a/src/app/api/pane_graphics.rs b/src/app/api/pane_graphics.rs
index 7851f69a..f5f5f2b7 100644
--- a/src/app/api/pane_graphics.rs
+++ b/src/app/api/pane_graphics.rs
@@ -2,121 +2,126 @@ use base64::Engine;
use crate::api::schema::{
PaneGraphicsClearParams, PaneGraphicsSetParams, PaneGraphicsStreamParams, ResponseResult,
- PANE_GRAPHICS_SET_MAX_BYTES, PANE_GRAPHICS_STREAM_MAX_BYTES,
+ PANE_GRAPHICS_MAX_LAYERS_PER_PANE, PANE_GRAPHICS_PRIMARY_LAYER_ID, PANE_GRAPHICS_SET_MAX_BYTES,
+ PANE_GRAPHICS_STREAM_MAX_BYTES,
};
-use crate::app::state::PaneGraphicsLayer;
+use crate::app::pane_graphics::{Key as PaneGraphicsKey, Layer, Slot};
use crate::app::App;
use crate::layout::PaneId;
use super::responses::{encode_error, encode_success};
impl App {
+ /// Whether graphics for a pane can be placed on the surface rendered by this client.
+ ///
+ /// This deliberately follows the pane layout rather than pane existence: inactive
+ /// workspaces/tabs and panes hidden by zoom are not placeable. Short-lived UI modes do not
+ /// suspend the producer because the pane becomes visible again without a layout event.
+ fn pane_graphics_visible(&self, ws_idx: usize, pane_id: PaneId) -> bool {
+ if self.state.active != Some(ws_idx) {
+ return false;
+ }
+ let Some(tab) = self.state.workspaces[ws_idx].active_tab() else {
+ return false;
+ };
+ if tab.zoomed {
+ tab.layout.focused() == pane_id
+ } else {
+ tab.layout.pane_ids().contains(&pane_id)
+ }
+ }
+
pub(super) fn handle_pane_graphics_info(
&mut self,
id: String,
target: crate::api::schema::PaneTarget,
) -> String {
- if let Err(response) = require_pane_graphics_enabled(self, &id) {
+ if let Err(response) = require_enabled(self, &id) {
return response;
}
- if self.parse_pane_id(&target.pane_id).is_none() {
+ let Some((ws_idx, pane_id)) = self.parse_pane_id(&target.pane_id) else {
return pane_not_found(id, &target.pane_id);
- }
+ };
+ let pane_visible = self.pane_graphics_visible(ws_idx, pane_id);
if !self.state.host_cell_size.is_known() {
return encode_error(id, "cell_size_unavailable", "host cell size is unavailable");
}
+ let file_frame_directory = self
+ .direct_graphics_available
+ .then(|| self.pane_graphics_files.source_directory().ok())
+ .flatten();
+ let direct = file_frame_directory.is_some();
encode_success(
id,
ResponseResult::PaneGraphicsInfo {
cell_width_px: self.state.host_cell_size.width_px,
cell_height_px: self.state.host_cell_size.height_px,
+ pane_visible,
+ file_frame_directory: file_frame_directory
+ .map(|directory| directory.to_string_lossy().into_owned()),
+ file_frame_formats: if direct {
+ vec!["rgba".into(), "bgra".into()]
+ } else {
+ Vec::new()
+ },
+ file_frame_max_bytes: direct.then_some(PANE_GRAPHICS_STREAM_MAX_BYTES),
+ // Damage metadata never changes the complete canonical frame contract.
+ file_frame_damage: true,
+ max_layers_per_pane: PANE_GRAPHICS_MAX_LAYERS_PER_PANE,
+ pixel_mouse: self.pixel_mouse_available,
+ file_frame_transport: direct.then(|| "direct-kitty".into()),
},
)
}
+ pub(crate) fn attach_pane_graphics_stream_active(
+ &mut self,
+ params: &PaneGraphicsStreamParams,
+ active: std::sync::Arc<std::sync::atomic::AtomicBool>,
+ response: &str,
+ ) {
+ if serde_json::from_str::<crate::api::schema::SuccessResponse>(response).is_err() {
+ return;
+ }
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return;
+ };
+ let Ok(key) = graphics_key(pane_id, params.layer_id.as_deref()) else {
+ return;
+ };
+ self.pane_graphics
+ .attach_stream_active(&key, &params.owner, active);
+ }
+
pub(super) fn handle_pane_graphics_set(
&mut self,
id: String,
params: PaneGraphicsSetParams,
) -> String {
- if let Err(response) = require_pane_graphics_enabled(self, &id) {
+ if let Err(response) = require_enabled(self, &id) {
return response;
}
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- if self.state.pane_graphics_streams.contains_key(&pane_id) {
+ let key = match graphics_key(pane_id, params.layer_id.as_deref()) {
+ Ok(key) => key,
+ Err(message) => return encode_error(id, "invalid_layer_id", message),
+ };
+ self.reclaim_inactive_stream(&key);
+ if self
+ .pane_graphics
+ .slots
+ .get(&key)
+ .is_some_and(|slot| slot.stream_owner.is_some())
+ {
return encode_error(
id,
"stream_conflict",
- "pane already has an active graphics stream",
- );
- }
- self.set_pane_graphics_layer(id, pane_id, params)
- }
-
- fn set_pane_graphics_layer(
- &mut self,
- id: String,
- pane_id: PaneId,
- params: PaneGraphicsSetParams,
- ) -> String {
- if params.image_width == 0 || params.image_height == 0 {
- return encode_error(
- id,
- "invalid_image",
- "image_width and image_height must be greater than zero",
+ "pane graphics layer has an active stream",
);
}
- let max_bytes = if params.data.is_some() {
- PANE_GRAPHICS_STREAM_MAX_BYTES
- } else {
- PANE_GRAPHICS_SET_MAX_BYTES
- };
- let data = match params.data {
- Some(data) => data,
- None => match base64::engine::general_purpose::STANDARD.decode(params.data_base64) {
- Ok(data) => data,
- Err(_) => {
- return encode_error(id, "invalid_image", "data_base64 is not valid base64");
- }
- },
- };
- if data.is_empty() {
- return encode_error(id, "invalid_image", "image data must not be empty");
- }
- if data.len() > max_bytes {
- return encode_error(id, "image_too_large", "image data is too large");
- }
- match pane_graphics_expected_data_len(
- params.format,
- params.image_width,
- params.image_height,
- ) {
- Ok(Some(expected_len)) if data.len() != expected_len => {
- return encode_error(
- id,
- "invalid_image",
- "image data length does not match format and dimensions",
- );
- }
- Ok(_) => {}
- Err(()) => {
- return encode_error(id, "invalid_image", "image dimensions are too large");
- }
- }
-
- let layer = PaneGraphicsLayer::new(
- params.format,
- params.image_width,
- params.image_height,
- data,
- params.placement,
- );
- self.state.pane_graphics_layers.insert(pane_id, layer);
- self.state.pane_graphics_revision = self.state.pane_graphics_revision.wrapping_add(1);
-
- encode_success(id, ResponseResult::Ok {})
+ self.set_layer(id, key, params)
}
pub(super) fn handle_pane_graphics_clear(
@@ -124,23 +129,32 @@ impl App {
id: String,
params: PaneGraphicsClearParams,
) -> String {
- if let Err(response) = require_pane_graphics_enabled(self, &id) {
+ if let Err(response) = require_enabled(self, &id) {
return response;
}
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- if self.state.pane_graphics_streams.contains_key(&pane_id) {
+ let key = match graphics_key(pane_id, params.layer_id.as_deref()) {
+ Ok(key) => key,
+ Err(message) => return encode_error(id, "invalid_layer_id", message),
+ };
+ self.reclaim_inactive_stream(&key);
+ if self
+ .pane_graphics
+ .slots
+ .get(&key)
+ .is_some_and(|slot| slot.stream_owner.is_some())
+ {
return encode_error(
id,
"stream_conflict",
- "pane already has an active graphics stream",
+ "pane graphics layer has an active stream",
);
}
- if self.state.pane_graphics_layers.remove(&pane_id).is_some() {
- self.state.pane_graphics_revision = self.state.pane_graphics_revision.wrapping_add(1);
+ if self.pane_graphics.slots.remove(&key).is_some() {
+ self.pane_graphics.mark_changed();
}
-
encode_success(id, ResponseResult::Ok {})
}
@@ -149,24 +163,32 @@ impl App {
id: String,
params: PaneGraphicsSetParams,
) -> String {
- if let Err(response) = require_pane_graphics_enabled(self, &id) {
+ if let Err(response) = require_enabled(self, &id) {
return response;
}
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- match self.state.pane_graphics_streams.get(&pane_id) {
- Some(owner) if owner == &params.owner => {}
- Some(_) => {
- return encode_error(
- id,
- "stream_conflict",
- "pane graphics stream owner does not match active stream",
- );
+ let key = match graphics_key(pane_id, params.layer_id.as_deref()) {
+ Ok(key) => key,
+ Err(message) => return encode_error(id, "invalid_layer_id", message),
+ };
+ match self.pane_graphics.slots.get(&key) {
+ Some(slot)
+ if slot.stream_owner.as_ref() == Some(&params.owner) && slot.stream_is_active() =>
+ {
+ self.set_layer(id, key, params)
+ }
+ Some(slot) if slot.stream_owner.as_ref() == Some(&params.owner) => {
+ encode_error(id, "stream_closed", "pane graphics stream is not active")
}
- None => return encode_error(id, "stream_closed", "pane graphics stream is not active"),
+ Some(_) => encode_error(
+ id,
+ "stream_conflict",
+ "pane graphics stream owner does not match active stream",
+ ),
+ None => encode_error(id, "stream_closed", "pane graphics stream is not active"),
}
- self.set_pane_graphics_layer(id, pane_id, params)
}
pub(super) fn handle_pane_graphics_stream_open(
@@ -174,7 +196,7 @@ impl App {
id: String,
params: PaneGraphicsStreamParams,
) -> String {
- if let Err(response) = require_pane_graphics_enabled(self, &id) {
+ if let Err(response) = require_enabled(self, &id) {
return response;
}
if params.owner.is_empty() {
@@ -184,22 +206,57 @@ impl App {
"pane graphics stream owner is required",
);
}
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- if self.state.pane_graphics_streams.contains_key(&pane_id) {
+ let key = match graphics_key(pane_id, params.layer_id.as_deref()) {
+ Ok(key) => key,
+ Err(message) => return encode_error(id, "invalid_layer_id", message),
+ };
+ let stale = self
+ .pane_graphics
+ .slots
+ .get(&key)
+ .is_some_and(|slot| slot.stream_owner.is_some() && !slot.stream_is_active());
+ if stale {
+ self.pane_graphics.slots.remove(&key);
+ }
+ if self
+ .pane_graphics
+ .slots
+ .get(&key)
+ .and_then(|slot| slot.stream_owner.as_ref())
+ .is_some()
+ {
return encode_error(
id,
"stream_conflict",
- "pane already has an active graphics stream",
+ "pane graphics layer has an active stream",
);
}
- self.state
- .pane_graphics_streams
- .insert(pane_id, params.owner);
- self.state.pane_graphics_layers.remove(&pane_id);
- self.state.pane_graphics_revision = self.state.pane_graphics_revision.wrapping_add(1);
-
+ let exists = self.pane_graphics.slots.contains_key(&key);
+ if !self.pane_graphics.can_add_slot(&key)
+ || (!exists
+ && self.pane_graphics.layer_count(pane_id) >= PANE_GRAPHICS_MAX_LAYERS_PER_PANE)
+ {
+ return encode_error(id, "layer_limit", "pane graphics layer limit reached");
+ }
+ let Some(host_image_id) = self.pane_graphics.reserve_image_id(&key) else {
+ return encode_error(id, "layer_limit", "pane graphics image ids are exhausted");
+ };
+ self.pane_graphics.slots.insert(
+ key,
+ Slot {
+ host_image_id,
+ layer: None,
+ stream_owner: Some(params.owner),
+ stream_active: Some(std::sync::Arc::new(std::sync::atomic::AtomicBool::new(
+ true,
+ ))),
+ direct_gate: None,
+ },
+ );
+ self.pane_graphics.mark_changed();
encode_success(id, ResponseResult::Ok {})
}
@@ -208,55 +265,286 @@ impl App {
id: String,
params: PaneGraphicsStreamParams,
) -> String {
- let Some((_ws_idx, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
return pane_not_found(id, &params.pane_id);
};
- if self
- .state
- .pane_graphics_streams
- .get(&pane_id)
- .is_some_and(|owner| owner == &params.owner)
+ if let Ok(key) = graphics_key(pane_id, params.layer_id.as_deref()) {
+ if self
+ .pane_graphics
+ .slots
+ .get(&key)
+ .and_then(|slot| slot.stream_owner.as_ref())
+ .is_some_and(|owner| owner == &params.owner)
+ {
+ self.pane_graphics.slots.remove(&key);
+ self.pane_graphics.mark_changed();
+ }
+ }
+ encode_success(id, ResponseResult::Ok {})
+ }
+
+ pub(super) fn handle_pane_graphics_stream_direct(
+ &mut self,
+ id: String,
+ params: crate::api::schema::PaneGraphicsDirectParams,
+ ) -> String {
+ if let Err(response) = require_enabled(self, &id) {
+ return response;
+ }
+ let Some((_, pane_id)) = self.parse_pane_id(&params.pane_id) else {
+ return pane_not_found(id, &params.pane_id);
+ };
+ let key = match graphics_key(pane_id, params.layer_id.as_deref()) {
+ Ok(key) => key,
+ Err(message) => return encode_error(id, "invalid_layer_id", message),
+ };
+ let owner_matches = self.pane_graphics.slots.get(&key).is_some_and(|slot| {
+ slot.stream_owner.as_deref() == Some(params.owner.as_str()) && slot.stream_is_active()
+ });
+ if !owner_matches {
+ return encode_error(id, "stream_closed", "pane graphics stream is not active");
+ }
+ if params.image_width == 0 || params.image_height == 0 {
+ return encode_error(
+ id,
+ "invalid_image",
+ "image_width and image_height must be greater than zero",
+ );
+ }
+ if !matches!(
+ params.format,
+ crate::api::schema::PaneGraphicsFormat::Rgba
+ | crate::api::schema::PaneGraphicsFormat::Bgra
+ ) {
+ return encode_error(id, "invalid_image", "direct frames require rgba or bgra");
+ }
+ let expected_len =
+ match expected_len(params.format, params.image_width, params.image_height) {
+ Ok(Some(len)) if len <= PANE_GRAPHICS_STREAM_MAX_BYTES => len,
+ _ => return encode_error(id, "invalid_image", "invalid direct RGBA dimensions"),
+ };
+ let lease = match self
+ .pane_graphics_files
+ .lease(std::path::Path::new(&params.path), expected_len)
{
- self.state.pane_graphics_streams.remove(&pane_id);
- self.state.pane_graphics_layers.remove(&pane_id);
- self.state.pane_graphics_revision = self.state.pane_graphics_revision.wrapping_add(1);
+ Ok(lease) => lease,
+ Err(err) => return encode_error(id, "invalid_frame_file", err.to_string()),
+ };
+ let primary = key.1 == PANE_GRAPHICS_PRIMARY_LAYER_ID;
+ let direct = self.direct_graphics_available
+ && primary
+ && params.format == crate::api::schema::PaneGraphicsFormat::Rgba;
+ if !direct && !self.pane_graphics.can_store_inline(&key, expected_len) {
+ return encode_error(
+ id,
+ "graphics_budget_exceeded",
+ "pane graphics inline memory limit reached",
+ );
+ }
+ let layer = if direct {
+ Layer::direct(
+ params.image_width,
+ params.image_height,
+ lease,
+ params.placement,
+ params.z_index,
+ )
+ } else {
+ let mut data = match lease.copy_rgba() {
+ Ok(data) => data,
+ Err(err) => return encode_error(id, "invalid_frame_file", err.to_string()),
+ };
+ canonicalize_bgra(params.format, &mut data);
+ Layer::inline(
+ crate::api::schema::PaneGraphicsFormat::Rgba,
+ params.image_width,
+ params.image_height,
+ data,
+ params.placement,
+ params.z_index,
+ )
+ };
+ let Some(slot) = self.pane_graphics.slots.get_mut(&key) else {
+ return encode_error(id, "stream_closed", "pane graphics stream is not active");
+ };
+ if !slot.stream_is_active() {
+ return encode_error(id, "stream_closed", "pane graphics stream is not active");
}
+ slot.layer = Some(layer);
+ slot.direct_gate = None;
+ self.pane_graphics.mark_changed();
+ encode_success(
+ id,
+ ResponseResult::PaneGraphicsFrameAck {
+ sequence: params.sequence,
+ revision: params.revision,
+ },
+ )
+ }
+ fn set_layer(
+ &mut self,
+ id: String,
+ key: PaneGraphicsKey,
+ params: PaneGraphicsSetParams,
+ ) -> String {
+ if !self.pane_graphics.can_add_slot(&key)
+ || (!self.pane_graphics.slots.contains_key(&key)
+ && self.pane_graphics.layer_count(key.0) >= PANE_GRAPHICS_MAX_LAYERS_PER_PANE)
+ {
+ return encode_error(id, "layer_limit", "pane graphics layer limit reached");
+ }
+ if params.image_width == 0 || params.image_height == 0 {
+ return encode_error(
+ id,
+ "invalid_image",
+ "image_width and image_height must be greater than zero",
+ );
+ }
+ let expected = match expected_len(params.format, params.image_width, params.image_height) {
+ Ok(expected) => expected,
+ Err(()) => return encode_error(id, "invalid_image", "image dimensions are too large"),
+ };
+ let streamed = params.data.is_some();
+ let mut data = match params.data {
+ Some(data) => data,
+ None => match base64::engine::general_purpose::STANDARD.decode(params.data_base64) {
+ Ok(data) => data,
+ Err(_) => {
+ return encode_error(id, "invalid_image", "data_base64 is not valid base64")
+ }
+ },
+ };
+ let limit = if streamed {
+ PANE_GRAPHICS_STREAM_MAX_BYTES
+ } else {
+ PANE_GRAPHICS_SET_MAX_BYTES
+ };
+ if data.is_empty() {
+ return encode_error(id, "invalid_image", "image data must not be empty");
+ }
+ if data.len() > limit {
+ return encode_error(id, "image_too_large", "image data is too large");
+ }
+ if expected.is_some_and(|size| size != data.len()) {
+ return encode_error(
+ id,
+ "invalid_image",
+ "image data does not match the frame contract",
+ );
+ }
+ let format = canonicalize_bgra(params.format, &mut data);
+ if !self.pane_graphics.can_store_inline(&key, data.len()) {
+ return encode_error(
+ id,
+ "graphics_budget_exceeded",
+ "pane graphics inline memory limit reached",
+ );
+ }
+ let Some(host_image_id) = self.pane_graphics.reserve_image_id(&key) else {
+ return encode_error(id, "layer_limit", "pane graphics image ids are exhausted");
+ };
+ // Move the liveness handle out before replacing the slot so Slot::drop cannot
+ // deactivate the stream that the replacement still owns.
+ let (stream_owner, stream_active) = self
+ .pane_graphics
+ .slots
+ .get_mut(&key)
+ .map(|slot| (slot.stream_owner.take(), slot.stream_active.take()))
+ .unwrap_or_default();
+ self.pane_graphics.slots.insert(
+ key,
+ Slot {
+ host_image_id,
+ layer: Some(Layer::inline(
+ format,
+ params.image_width,
+ params.image_height,
+ data,
+ params.placement,
+ params.z_index,
+ )),
+ stream_owner,
+ stream_active,
+ direct_gate: None,
+ },
+ );
+ self.pane_graphics.mark_changed();
encode_success(id, ResponseResult::Ok {})
}
+
+ fn reclaim_inactive_stream(&mut self, key: &PaneGraphicsKey) {
+ let stale = self
+ .pane_graphics
+ .slots
+ .get(key)
+ .is_some_and(|slot| slot.stream_owner.is_some() && !slot.stream_is_active());
+ if stale {
+ self.pane_graphics.slots.remove(key);
+ self.pane_graphics.mark_changed();
+ }
+ }
+}
+
+fn canonicalize_bgra(
+ format: crate::api::schema::PaneGraphicsFormat,
+ data: &mut [u8],
+) -> crate::api::schema::PaneGraphicsFormat {
+ if format != crate::api::schema::PaneGraphicsFormat::Bgra {
+ return format;
+ }
+ for pixel in data.chunks_exact_mut(4) {
+ pixel.swap(0, 2);
+ }
+ crate::api::schema::PaneGraphicsFormat::Rgba
}
-fn require_pane_graphics_enabled(app: &App, id: &str) -> Result<(), String> {
- if app.state.kitty_graphics_enabled {
- return Ok(());
+fn graphics_key(pane_id: PaneId, layer_id: Option<&str>) -> Result<PaneGraphicsKey, &'static str> {
+ let layer_id = layer_id.unwrap_or(PANE_GRAPHICS_PRIMARY_LAYER_ID);
+ if layer_id.is_empty() || layer_id.len() > 64 {
+ return Err("layer_id must contain between 1 and 64 characters");
}
- Err(encode_error(
- id.to_owned(),
- "feature_disabled",
- "pane graphics require experimental.kitty_graphics",
- ))
+ if !layer_id
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
+ {
+ return Err("layer_id contains unsupported characters");
+ }
+ Ok((pane_id, layer_id.to_owned()))
}
-fn pane_graphics_expected_data_len(
+fn expected_len(
format: crate::api::schema::PaneGraphicsFormat,
- image_width: u32,
- image_height: u32,
+ width: u32,
+ height: u32,
) -> Result<Option<usize>, ()> {
- let bytes_per_pixel = match format {
+ let bytes = match format {
crate::api::schema::PaneGraphicsFormat::Png => return Ok(None),
crate::api::schema::PaneGraphicsFormat::Rgb => 3_u64,
- crate::api::schema::PaneGraphicsFormat::Rgba => 4_u64,
+ crate::api::schema::PaneGraphicsFormat::Rgba
+ | crate::api::schema::PaneGraphicsFormat::Bgra => 4_u64,
};
- let pixels = u64::from(image_width)
- .checked_mul(u64::from(image_height))
- .ok_or(())?;
- pixels
- .checked_mul(bytes_per_pixel)
- .and_then(|bytes| usize::try_from(bytes).ok())
+ u64::from(width)
+ .checked_mul(u64::from(height))
+ .and_then(|pixels| pixels.checked_mul(bytes))
+ .and_then(|size| usize::try_from(size).ok())
.map(Some)
.ok_or(())
}
+fn require_enabled(app: &App, id: &str) -> Result<(), String> {
+ app.state
+ .kitty_graphics_enabled
+ .then_some(())
+ .ok_or_else(|| {
+ encode_error(
+ id.to_owned(),
+ "feature_disabled",
+ "pane graphics require experimental.kitty_graphics",
+ )
+ })
+}
+
fn pane_not_found(id: String, pane_id: &str) -> String {
encode_error(id, "pane_not_found", format!("pane {pane_id} not found"))
}
@@ -268,435 +556,667 @@ mod tests {
use crate::config::Config;
use crate::workspace::Workspace;
- fn app_with_test_workspace() -> (App, String) {
- let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ fn app() -> (App, String) {
+ let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&Config::default(),
true,
None,
- api_rx,
+ rx,
crate::api::EventHub::default(),
);
- app.state.workspaces = vec![Workspace::test_new("pane-graphics")];
+ app.state.workspaces = vec![Workspace::test_new("graphics")];
app.state.ensure_test_terminals();
- let pane_id = app.state.workspaces[0].tabs[0].root_pane;
- let public_pane_id = app.public_pane_id(0, pane_id).unwrap();
- (app, public_pane_id)
+ let pane = app.state.workspaces[0].tabs[0].root_pane;
+ let public = app.public_pane_id(0, pane).unwrap();
+ app.state.kitty_graphics_enabled = true;
+ (app, public)
+ }
+
+ fn frame(
+ pane_id: String,
+ layer_id: Option<&str>,
+ owner: &str,
+ value: u8,
+ ) -> PaneGraphicsSetParams {
+ PaneGraphicsSetParams {
+ pane_id,
+ layer_id: layer_id.map(str::to_owned),
+ z_index: 2,
+ owner: owner.to_owned(),
+ format: crate::api::schema::PaneGraphicsFormat::Rgba,
+ image_width: 1,
+ image_height: 1,
+ data: Some(vec![value; 4]),
+ data_base64: String::new(),
+ placement: Default::default(),
+ }
+ }
+
+ fn pane_visible(response: &str) -> bool {
+ serde_json::from_str::<serde_json::Value>(response).unwrap()["result"]["pane_visible"]
+ .as_bool()
+ .unwrap()
}
#[test]
- fn api_pane_graphics_info_is_explicit_and_side_effect_free() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- let request = |pane_id: String| crate::api::schema::Request {
- id: "graphics-info".into(),
- method: crate::api::schema::Method::PaneGraphicsInfo(crate::api::schema::PaneTarget {
- pane_id,
- }),
+ fn info_reports_visibility_for_terminal_surface_workspace_tab_and_zoom() {
+ let (mut app, pane_id) = app();
+ app.state.mode = crate::app::Mode::Terminal;
+ app.state.active = Some(0);
+ app.state.host_cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
};
+ let visible = app.handle_pane_graphics_info(
+ "visible".into(),
+ crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ },
+ );
+ assert!(pane_visible(&visible));
- let response = app.handle_api_request(request(public_pane_id.clone()));
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "feature_disabled");
+ let hidden_workspace = Workspace::test_new("hidden");
+ let hidden_workspace_pane = hidden_workspace.tabs[0].root_pane;
+ app.state.workspaces.push(hidden_workspace);
+ let hidden_workspace_id = app.public_pane_id(1, hidden_workspace_pane).unwrap();
+ let hidden = app.handle_pane_graphics_info(
+ "hidden-workspace".into(),
+ crate::api::schema::PaneTarget {
+ pane_id: hidden_workspace_id,
+ },
+ );
+ assert!(!pane_visible(&hidden));
- app.state.kitty_graphics_enabled = true;
+ let inactive_tab = app.state.workspaces[0].test_add_tab(Some("inactive"));
+ let inactive_pane = app.state.workspaces[0].tabs[inactive_tab].root_pane;
+ let inactive_id = app.public_pane_id(0, inactive_pane).unwrap();
+ let hidden_tab = app.handle_pane_graphics_info(
+ "hidden-tab".into(),
+ crate::api::schema::PaneTarget {
+ pane_id: inactive_id,
+ },
+ );
+ assert!(!pane_visible(&hidden_tab));
+
+ app.state.workspaces[0].test_split(ratatui::layout::Direction::Horizontal);
+ app.state.workspaces[0].tabs[0].zoomed = true;
+ let zoomed_away = app.handle_pane_graphics_info(
+ "zoomed-away".into(),
+ crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
+ },
+ );
+ assert!(!pane_visible(&zoomed_away));
+
+ app.state.workspaces[0].tabs[0].zoomed = false;
+ app.state.mode = crate::app::Mode::Navigate;
+ let short_lived_mode = app.handle_pane_graphics_info(
+ "navigate".into(),
+ crate::api::schema::PaneTarget { pane_id },
+ );
+ assert!(pane_visible(&short_lived_mode));
+ }
+
+ #[test]
+ fn monolithic_info_keeps_fast_transport_and_exact_pixels_disabled() {
+ let (mut app, pane_id) = app();
app.state.host_cell_size = crate::kitty_graphics::HostCellSize {
- width_px: 11,
- height_px: 22,
+ width_px: 10,
+ height_px: 20,
};
- let response = app.handle_api_request(request(public_pane_id));
+ assert!(!app.pane_graphics_files.is_initialized());
+ let response = app
+ .handle_pane_graphics_info("info".into(), crate::api::schema::PaneTarget { pane_id });
let success: SuccessResponse = serde_json::from_str(&response).unwrap();
assert!(matches!(
success.result,
ResponseResult::PaneGraphicsInfo {
- cell_width_px: 11,
- cell_height_px: 22,
+ file_frame_damage: true,
+ max_layers_per_pane: 16,
+ pixel_mouse: false,
+ ..
}
));
- assert!(app.state.pane_graphics_layers.is_empty());
- assert!(app.state.pane_graphics_streams.is_empty());
+ assert!(app.pane_graphics.slots.is_empty());
+ assert!(!app.pane_graphics_files.is_initialized());
}
+ #[cfg(unix)]
#[test]
- fn api_pane_graphics_set_and_clear_updates_runtime_layers() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let data = base64::engine::general_purpose::STANDARD.encode([1_u8, 2, 3, 4]);
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-set".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id.clone(),
- owner: String::new(),
- format: crate::api::schema::PaneGraphicsFormat::Rgba,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: data,
- placement: crate::api::schema::PaneGraphicsPlacementParams {
- grid_cols: 10,
- grid_rows: 4,
- ..Default::default()
- },
- }),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
+ fn info_advertises_rgba_direct_and_bgra_fallback_file_formats_on_demand() {
+ let (mut app, pane_id) = app();
+ app.state.host_cell_size = crate::kitty_graphics::HostCellSize {
+ width_px: 10,
+ height_px: 20,
+ };
+ app.direct_graphics_available = true;
+ let response = app
+ .handle_pane_graphics_info("info".into(), crate::api::schema::PaneTarget { pane_id });
+ let value: serde_json::Value = serde_json::from_str(&response).unwrap();
+ assert!(app.pane_graphics_files.is_initialized());
+ assert_eq!(
+ value["result"]["file_frame_formats"],
+ serde_json::json!(["rgba", "bgra"])
+ );
+ assert_eq!(value["result"]["file_frame_damage"], true);
+ assert_eq!(value["result"]["file_frame_transport"], "direct-kitty");
+ }
- let layer = app
- .state
- .pane_graphics_layers
- .get(&pane_id)
- .expect("graphics layer stored");
- assert_eq!(layer.image_width, 1);
- assert_eq!(layer.image_height, 1);
- assert_eq!(layer.render.grid_cols, 10);
- assert_eq!(layer.render.grid_rows, 4);
- assert!(!app.state.session_dirty);
+ #[test]
+ fn layers_are_isolated_and_stream_owned() {
+ let (mut app, pane_id) = app();
+ for (layer, z, value) in [(None, 0, 1), (Some("chrome"), 10, 2)] {
+ let mut request = frame(pane_id.clone(), layer, "", value);
+ request.z_index = z;
+ let response = app.handle_pane_graphics_set(format!("set-{value}"), request);
+ assert!(serde_json::from_str::<SuccessResponse>(&response).is_ok());
+ }
+ let (_, pane) = app.parse_pane_id(&pane_id).unwrap();
+ assert_eq!(
+ app.pane_graphics
+ .slots
+ .get(&(pane, PANE_GRAPHICS_PRIMARY_LAYER_ID.into()))
+ .and_then(|slot| slot.layer.as_ref())
+ .unwrap()
+ .z_index,
+ 0
+ );
+ assert_eq!(
+ app.pane_graphics
+ .slots
+ .get(&(pane, "chrome".into()))
+ .and_then(|slot| slot.layer.as_ref())
+ .unwrap()
+ .z_index,
+ 10
+ );
+ let response = app.handle_pane_graphics_stream_open(
+ "open".into(),
+ PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: Some("chrome".into()),
+ z_index: 10,
+ owner: "stream".into(),
+ },
+ );
+ assert!(serde_json::from_str::<SuccessResponse>(&response).is_ok());
+ let response =
+ app.handle_pane_graphics_set("conflict".into(), frame(pane_id, Some("chrome"), "", 3));
+ let error: ErrorResponse = serde_json::from_str(&response).unwrap();
+ assert_eq!(error.error.code, "stream_conflict");
+ }
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-clear".into(),
- method: crate::api::schema::Method::PaneGraphicsClear(PaneGraphicsClearParams {
- pane_id: public_pane_id,
- }),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ fn error_code(response: &str) -> String {
+ serde_json::from_str::<ErrorResponse>(response)
+ .unwrap()
+ .error
+ .code
}
#[test]
- fn api_pane_graphics_rejects_when_kitty_graphics_disabled() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let data = base64::engine::general_purpose::STANDARD.encode([1_u8, 2, 3, 4]);
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-set".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id.clone(),
- owner: String::new(),
- format: crate::api::schema::PaneGraphicsFormat::Rgba,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: data,
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "feature_disabled");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-clear".into(),
- method: crate::api::schema::Method::PaneGraphicsClear(PaneGraphicsClearParams {
- pane_id: public_pane_id,
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "feature_disabled");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ fn validates_layer_and_frame_contracts() {
+ let cases = [
+ (Some("bad space"), 1, 1, vec![1; 4], "invalid_layer_id"),
+ (None, 0, 1, vec![1; 4], "invalid_image"),
+ (None, 1, 1, Vec::new(), "invalid_image"),
+ (None, 1, 1, vec![1; 3], "invalid_image"),
+ ];
+ for (layer, width, height, data, expected) in cases {
+ let (mut app, pane_id) = app();
+ let mut params = frame(pane_id, layer, "", 1);
+ params.image_width = width;
+ params.image_height = height;
+ params.data = Some(data);
+ let response = app.handle_pane_graphics_set("invalid".into(), params);
+ assert_eq!(error_code(&response), expected);
+ assert!(app.pane_graphics.slots.is_empty());
+ }
}
#[test]
- fn api_pane_graphics_stream_claim_uses_resolved_pane_identity() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let alias = format!("{public_pane_id}:alias");
- app.state
- .public_pane_id_aliases
- .insert(alias.clone(), pane_id);
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-open".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamOpen(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: public_pane_id,
- owner: "stream-1".into(),
- },
- ),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
+ fn disabled_requests_have_no_graphics_side_effects() {
+ let (mut app, pane_id) = app();
+ app.state.kitty_graphics_enabled = false;
+ let revision = app.pane_graphics.revision();
+
+ let response = app.handle_pane_graphics_set("disabled".into(), frame(pane_id, None, "", 1));
+
+ assert_eq!(error_code(&response), "feature_disabled");
+ assert!(app.pane_graphics.slots.is_empty());
+ assert_eq!(app.pane_graphics.revision(), revision);
+ }
+
+ #[test]
+ fn stream_owner_controls_only_its_named_layer() {
+ let (mut app, pane_id) = app();
+ let params = |owner: &str| PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: Some("chrome".into()),
+ z_index: 7,
+ owner: owner.into(),
+ };
+ assert!(serde_json::from_str::<SuccessResponse>(
+ &app.handle_pane_graphics_stream_open("open".into(), params("owner"))
+ )
+ .is_ok());
assert_eq!(
- app.state.pane_graphics_streams.get(&pane_id),
- Some(&"stream-1".to_string())
+ error_code(&app.handle_pane_graphics_stream_set(
+ "wrong".into(),
+ frame(pane_id.clone(), Some("chrome"), "other", 2),
+ )),
+ "stream_conflict"
+ );
+ assert!(
+ serde_json::from_str::<SuccessResponse>(&app.handle_pane_graphics_stream_set(
+ "frame".into(),
+ frame(pane_id.clone(), Some("chrome"), "owner", 3),
+ ))
+ .is_ok()
);
+ app.handle_pane_graphics_stream_close("stale".into(), params("other"));
+ assert_eq!(app.pane_graphics.slots.len(), 1);
+ app.handle_pane_graphics_stream_close("close".into(), params("owner"));
+ assert!(app.pane_graphics.slots.is_empty());
+ }
+
+ #[test]
+ fn static_set_and_clear_reclaim_inactive_stream_layers() {
+ let (mut app, pane_id) = app();
+ let open = |layer: &str| PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: Some(layer.into()),
+ z_index: 0,
+ owner: format!("owner-{layer}"),
+ };
+ for layer in ["set", "clear"] {
+ assert!(serde_json::from_str::<SuccessResponse>(
+ &app.handle_pane_graphics_stream_open(layer.into(), open(layer))
+ )
+ .is_ok());
+ let (_, pane) = app.parse_pane_id(&pane_id).unwrap();
+ app.pane_graphics.slots[&(pane, layer.into())]
+ .stream_active
+ .as_ref()
+ .unwrap()
+ .store(false, std::sync::atomic::Ordering::Release);
+ }
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-open-alias".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamOpen(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: alias.clone(),
- owner: "stream-2".into(),
+ assert!(
+ serde_json::from_str::<SuccessResponse>(&app.handle_pane_graphics_set(
+ "set-static".into(),
+ frame(pane_id.clone(), Some("set"), "", 4),
+ ))
+ .is_ok()
+ );
+ assert!(
+ serde_json::from_str::<SuccessResponse>(&app.handle_pane_graphics_clear(
+ "clear-static".into(),
+ PaneGraphicsClearParams {
+ pane_id,
+ layer_id: Some("clear".into()),
},
- ),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "stream_conflict");
- assert_eq!(
- app.state.pane_graphics_streams.get(&pane_id),
- Some(&"stream-1".to_string())
+ ))
+ .is_ok()
);
+ assert_eq!(app.pane_graphics.slots.len(), 1);
+ assert!(app
+ .pane_graphics
+ .slots
+ .values()
+ .all(|slot| slot.stream_owner.is_none()));
+ }
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-close-wrong-owner".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamClose(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: alias.clone(),
- owner: "stream-2".into(),
+ #[test]
+ fn layer_limit_counts_empty_and_populated_stream_slots() {
+ let (mut app, pane_id) = app();
+ for index in 0..PANE_GRAPHICS_MAX_LAYERS_PER_PANE {
+ let layer_id = format!("layer-{index}");
+ let response = app.handle_pane_graphics_stream_open(
+ layer_id.clone(),
+ PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: Some(layer_id),
+ z_index: index as i32,
+ owner: format!("owner-{index}"),
},
- ),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert_eq!(
- app.state.pane_graphics_streams.get(&pane_id),
- Some(&"stream-1".to_string())
+ );
+ assert!(serde_json::from_str::<SuccessResponse>(&response).is_ok());
+ }
+ let response = app.handle_pane_graphics_set(
+ "set-overflow".into(),
+ frame(pane_id.clone(), Some("set-overflow"), "", 1),
+ );
+ assert_eq!(error_code(&response), "layer_limit");
+ let response = app.handle_pane_graphics_stream_open(
+ "overflow".into(),
+ PaneGraphicsStreamParams {
+ pane_id,
+ layer_id: Some("overflow".into()),
+ z_index: 0,
+ owner: "overflow".into(),
+ },
);
+ assert_eq!(error_code(&response), "layer_limit");
+ assert_eq!(app.pane_graphics.slots.len(), 16);
+ }
+
+ #[test]
+ fn set_and_clear_preserve_runtime_only_layer_details() {
+ let (mut app, pane_id) = app();
+ let (_, pane) = app.parse_pane_id(&pane_id).unwrap();
+ let mut params = frame(pane_id.clone(), None, "", 9);
+ params.placement.grid_cols = 10;
+ params.placement.grid_rows = 4;
+ assert!(serde_json::from_str::<SuccessResponse>(
+ &app.handle_pane_graphics_set("set".into(), params)
+ )
+ .is_ok());
+ let layer = app
+ .pane_graphics
+ .slots
+ .get(&(pane, PANE_GRAPHICS_PRIMARY_LAYER_ID.into()))
+ .and_then(|slot| slot.layer.as_ref())
+ .unwrap();
+ assert_eq!(layer.inline_data(), Some([9; 4].as_slice()));
+ assert_eq!((layer.render.grid_cols, layer.render.grid_rows), (10, 4));
+ assert!(!app.state.session_dirty);
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-close-alias".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamClose(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: alias,
- owner: "stream-1".into(),
+ assert!(
+ serde_json::from_str::<SuccessResponse>(&app.handle_pane_graphics_clear(
+ "clear".into(),
+ PaneGraphicsClearParams {
+ pane_id,
+ layer_id: None,
},
- ),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert!(!app.state.pane_graphics_streams.contains_key(&pane_id));
+ ))
+ .is_ok()
+ );
+ assert!(app.pane_graphics.slots.is_empty());
}
#[test]
- fn api_pane_graphics_stream_owns_layer_while_active() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let public_data = base64::engine::general_purpose::STANDARD.encode([1_u8, 2, 3, 4]);
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-open".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamOpen(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: public_pane_id.clone(),
- owner: "stream-1".into(),
- },
- ),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-set-public".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id.clone(),
- owner: String::new(),
- format: crate::api::schema::PaneGraphicsFormat::Rgba,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: public_data.clone(),
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "stream_conflict");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-set-stream".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamSet(PaneGraphicsSetParams {
- pane_id: public_pane_id.clone(),
- owner: "stream-1".into(),
- format: crate::api::schema::PaneGraphicsFormat::Rgba,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: public_data,
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert!(app.state.pane_graphics_layers.contains_key(&pane_id));
-
- let wrong_owner_data = base64::engine::general_purpose::STANDARD.encode([5_u8, 6, 7, 8]);
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-set-stream-wrong-owner".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamSet(PaneGraphicsSetParams {
- pane_id: public_pane_id.clone(),
- owner: "stream-2".into(),
- format: crate::api::schema::PaneGraphicsFormat::Rgba,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: wrong_owner_data,
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "stream_conflict");
+ fn raw_formats_require_exact_non_overflowing_lengths() {
+ for (format, exact) in [
+ (crate::api::schema::PaneGraphicsFormat::Rgb, 6),
+ (crate::api::schema::PaneGraphicsFormat::Rgba, 8),
+ ] {
+ for (length, expected) in [(exact, "ok"), (exact - 1, "invalid_image")] {
+ let (mut app, pane_id) = app();
+ let mut params = frame(pane_id, None, "", 1);
+ params.format = format;
+ params.image_width = 2;
+ params.data = Some(vec![1; length]);
+ let response = app.handle_pane_graphics_set("length".into(), params);
+ if expected == "ok" {
+ assert!(serde_json::from_str::<SuccessResponse>(&response).is_ok());
+ } else {
+ assert_eq!(error_code(&response), expected);
+ }
+ }
+ let (mut app, pane_id) = app();
+ let mut params = frame(pane_id, None, "", 1);
+ params.format = format;
+ params.image_width = u32::MAX;
+ params.image_height = u32::MAX;
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_set("overflow".into(), params)),
+ "invalid_image"
+ );
+ }
+ }
+
+ #[test]
+ fn public_frames_validate_base64_and_public_size_limit() {
+ let (mut app, pane_id) = app();
+ let mut params = frame(pane_id.clone(), None, "", 1);
+ params.format = crate::api::schema::PaneGraphicsFormat::Png;
+ params.data = None;
+ params.data_base64 = "not base64".into();
assert_eq!(
- app.state.pane_graphics_layers.get(&pane_id).unwrap().data,
- vec![1_u8, 2, 3, 4]
+ error_code(&app.handle_pane_graphics_set("base64".into(), params)),
+ "invalid_image"
);
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-clear-public".into(),
- method: crate::api::schema::Method::PaneGraphicsClear(PaneGraphicsClearParams {
- pane_id: public_pane_id.clone(),
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "stream_conflict");
- assert!(app.state.pane_graphics_layers.contains_key(&pane_id));
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-close".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamClose(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: public_pane_id,
- owner: "stream-1".into(),
- },
- ),
- });
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ let mut params = frame(pane_id, None, "", 1);
+ params.format = crate::api::schema::PaneGraphicsFormat::Png;
+ params.data = None;
+ params.data_base64 =
+ base64::engine::general_purpose::STANDARD
+ .encode(vec![1; PANE_GRAPHICS_SET_MAX_BYTES + 1]);
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_set("large".into(), params)),
+ "image_too_large"
+ );
}
#[test]
- fn api_pane_graphics_rejects_overflowing_raw_dimensions() {
- for format in [
- crate::api::schema::PaneGraphicsFormat::Rgb,
- crate::api::schema::PaneGraphicsFormat::Rgba,
- ] {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
-
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-overflow".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id,
- owner: String::new(),
- format,
- image_width: u32::MAX,
- image_height: u32::MAX,
- data: None,
- data_base64: base64::engine::general_purpose::STANDARD.encode([1_u8]),
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
+ fn aliases_resolve_to_the_same_layer_identity() {
+ let (mut app, pane_id) = app();
+ let (_, pane) = app.parse_pane_id(&pane_id).unwrap();
+ let alias = format!("{pane_id}:alias");
+ app.state.public_pane_id_aliases.insert(alias.clone(), pane);
+ let params = |target: String, owner: &str| PaneGraphicsStreamParams {
+ pane_id: target,
+ layer_id: None,
+ z_index: 0,
+ owner: owner.into(),
+ };
+ assert!(serde_json::from_str::<SuccessResponse>(
+ &app.handle_pane_graphics_stream_open("open".into(), params(pane_id.clone(), "owner"),)
+ )
+ .is_ok());
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_stream_open(
+ "conflict".into(),
+ params(alias.clone(), "other"),
+ )),
+ "stream_conflict"
+ );
+ app.handle_pane_graphics_stream_close("stale".into(), params(alias.clone(), "other"));
+ assert_eq!(app.pane_graphics.slots.len(), 1);
+ app.handle_pane_graphics_stream_close("close".into(), params(alias, "owner"));
+ assert!(app.pane_graphics.slots.is_empty());
+ }
+
+ #[cfg(unix)]
+ fn direct_file(app: &App, name: &str, data: &[u8]) -> String {
+ use std::io::Write as _;
+ use std::os::unix::fs::OpenOptionsExt as _;
+ let path = app
+ .pane_graphics_files
+ .source_directory()
+ .unwrap()
+ .join(name);
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .mode(0o600)
+ .open(&path)
+ .unwrap();
+ file.write_all(data).unwrap();
+ path.to_string_lossy().into_owned()
+ }
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "invalid_image");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ #[cfg(unix)]
+ fn direct_params(
+ pane_id: String,
+ owner: &str,
+ path: String,
+ ) -> crate::api::schema::PaneGraphicsDirectParams {
+ crate::api::schema::PaneGraphicsDirectParams {
+ pane_id,
+ layer_id: None,
+ z_index: 0,
+ owner: owner.into(),
+ image_width: 1,
+ image_height: 1,
+ format: crate::api::schema::PaneGraphicsFormat::Rgba,
+ path,
+ sequence: 1,
+ revision: 1,
+ placement: Default::default(),
}
}
+ #[cfg(unix)]
#[test]
- fn api_pane_graphics_validates_exact_raw_lengths() {
- for (format, exact_len) in [
- (crate::api::schema::PaneGraphicsFormat::Rgb, 6_usize),
- (crate::api::schema::PaneGraphicsFormat::Rgba, 8_usize),
+ fn direct_file_is_leased_or_safely_copied_for_inline_fallback() {
+ let (mut app, pane_id) = app();
+ let stream = PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: None,
+ z_index: 0,
+ owner: "owner".into(),
+ };
+ app.handle_pane_graphics_stream_open("open".into(), stream.clone());
+ for (width, height) in [(0, 1), (1, 0)] {
+ let mut params = direct_params(pane_id.clone(), "owner", "unused".into());
+ (params.image_width, params.image_height) = (width, height);
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_stream_direct("invalid".into(), params)),
+ "invalid_image"
+ );
+ }
+ for format in [
+ crate::api::schema::PaneGraphicsFormat::Rgb,
+ crate::api::schema::PaneGraphicsFormat::Png,
] {
- for data_len in [exact_len, exact_len - 1] {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-raw-length".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id,
- owner: String::new(),
- format,
- image_width: 2,
- image_height: 1,
- data: None,
- data_base64: base64::engine::general_purpose::STANDARD
- .encode(vec![1_u8; data_len]),
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
-
- if data_len == exact_len {
- let success: SuccessResponse = serde_json::from_str(&response).unwrap();
- assert!(matches!(success.result, ResponseResult::Ok {}));
- assert!(app.state.pane_graphics_layers.contains_key(&pane_id));
- } else {
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "invalid_image");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
- }
- }
+ let mut params = direct_params(pane_id.clone(), "owner", "unused".into());
+ params.format = format;
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_stream_direct("invalid".into(), params)),
+ "invalid_image"
+ );
}
+ app.direct_graphics_available = true;
+ let path = direct_file(&app, "direct", &[1, 2, 3, 4]);
+ let response = app.handle_pane_graphics_stream_direct(
+ "direct".into(),
+ crate::api::schema::PaneGraphicsDirectParams {
+ sequence: 7,
+ revision: 8,
+ ..direct_params(pane_id.clone(), "owner", path)
+ },
+ );
+ let ack: SuccessResponse = serde_json::from_str(&response).unwrap();
+ assert!(matches!(
+ ack.result,
+ ResponseResult::PaneGraphicsFrameAck {
+ sequence: 7,
+ revision: 8
+ }
+ ));
+ assert!(app
+ .pane_graphics
+ .slots
+ .values()
+ .next()
+ .unwrap()
+ .layer
+ .as_ref()
+ .unwrap()
+ .direct_lease()
+ .is_some());
+
+ app.handle_pane_graphics_stream_close("close".into(), stream.clone());
+ app.handle_pane_graphics_stream_open("reopen".into(), stream);
+ app.direct_graphics_available = false;
+ let path = direct_file(&app, "fallback", &[5, 6, 7, 8]);
+ app.handle_pane_graphics_stream_direct(
+ "fallback".into(),
+ crate::api::schema::PaneGraphicsDirectParams {
+ sequence: 9,
+ revision: 10,
+ ..direct_params(pane_id, "owner", path)
+ },
+ );
+ assert_eq!(
+ app.pane_graphics
+ .slots
+ .values()
+ .next()
+ .unwrap()
+ .layer
+ .as_ref()
+ .unwrap()
+ .inline_data(),
+ Some([5, 6, 7, 8].as_slice())
+ );
}
+ #[cfg(unix)]
#[test]
- fn api_pane_graphics_uses_transport_specific_limits() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- app.state.kitty_graphics_enabled = true;
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let response = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-public-too-large".into(),
- method: crate::api::schema::Method::PaneGraphicsSet(PaneGraphicsSetParams {
- pane_id: public_pane_id,
- owner: String::new(),
- format: crate::api::schema::PaneGraphicsFormat::Png,
- image_width: 1,
- image_height: 1,
- data: None,
- data_base64: base64::engine::general_purpose::STANDARD.encode(vec![
- 1_u8;
- PANE_GRAPHICS_SET_MAX_BYTES
- + 1
- ]),
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
-
- let error: ErrorResponse = serde_json::from_str(&response).unwrap();
- assert_eq!(error.error.code, "image_too_large");
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ fn bgra_and_secondary_file_frames_are_canonical_owned_rgba() {
+ let (mut app, pane_id) = app();
+ app.direct_graphics_available = true;
+ for (layer, format, source) in [
+ (
+ "browser-chrome",
+ crate::api::schema::PaneGraphicsFormat::Rgba,
+ [1, 2, 3, 4],
+ ),
+ (
+ "primary",
+ crate::api::schema::PaneGraphicsFormat::Bgra,
+ [3, 2, 1, 4],
+ ),
+ ] {
+ app.handle_pane_graphics_stream_open(
+ format!("open-{layer}"),
+ PaneGraphicsStreamParams {
+ pane_id: pane_id.clone(),
+ layer_id: Some(layer.into()),
+ z_index: 0,
+ owner: layer.into(),
+ },
+ );
+ let path = direct_file(&app, layer, &source);
+ app.handle_pane_graphics_stream_direct(
+ format!("frame-{layer}"),
+ crate::api::schema::PaneGraphicsDirectParams {
+ layer_id: Some(layer.into()),
+ format,
+ ..direct_params(pane_id.clone(), layer, path)
+ },
+ );
+ let key = (app.parse_pane_id(&pane_id).unwrap().1, layer.into());
+ assert_eq!(
+ app.pane_graphics.slots[&key]
+ .layer
+ .as_ref()
+ .unwrap()
+ .inline_data(),
+ Some([1, 2, 3, 4].as_slice())
+ );
+ }
}
#[test]
- fn api_pane_graphics_stream_methods_require_kitty_graphics() {
- let (mut app, public_pane_id) = app_with_test_workspace();
- let (_, pane_id) = app.parse_pane_id(&public_pane_id).unwrap();
- let open = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-open".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamOpen(
- crate::api::schema::PaneGraphicsStreamParams {
- pane_id: public_pane_id.clone(),
- owner: "stream-1".into(),
+ fn disabled_info_and_stream_requests_are_rejected() {
+ let (mut app, pane_id) = app();
+ app.state.kitty_graphics_enabled = false;
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_info(
+ "info".into(),
+ crate::api::schema::PaneTarget {
+ pane_id: pane_id.clone(),
},
- ),
- });
- let error: ErrorResponse = serde_json::from_str(&open).unwrap();
- assert_eq!(error.error.code, "feature_disabled");
-
- let set = app.handle_api_request(crate::api::schema::Request {
- id: "graphics-stream-set".into(),
- method: crate::api::schema::Method::PaneGraphicsStreamSet(PaneGraphicsSetParams {
- pane_id: public_pane_id,
- owner: "stream-1".into(),
- format: crate::api::schema::PaneGraphicsFormat::Png,
- image_width: 1,
- image_height: 1,
- data: Some(vec![1_u8]),
- data_base64: String::new(),
- placement: crate::api::schema::PaneGraphicsPlacementParams::default(),
- }),
- });
- let error: ErrorResponse = serde_json::from_str(&set).unwrap();
- assert_eq!(error.error.code, "feature_disabled");
- assert!(!app.state.pane_graphics_streams.contains_key(&pane_id));
- assert!(!app.state.pane_graphics_layers.contains_key(&pane_id));
+ )),
+ "feature_disabled"
+ );
+ assert_eq!(
+ error_code(&app.handle_pane_graphics_stream_open(
+ "open".into(),
+ PaneGraphicsStreamParams {
+ pane_id,
+ layer_id: None,
+ z_index: 0,
+ owner: "owner".into(),
+ },
+ )),
+ "feature_disabled"
+ );
+ assert!(app.pane_graphics.slots.is_empty());
}
}
diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs
index a8cb4405..ddf3fba6 100644
--- a/src/app/api/plugins/mod.rs
+++ b/src/app/api/plugins/mod.rs
@@ -705,8 +705,10 @@ fn manifest_actions(
#[cfg(test)]
mod tests {
use super::*;
+ #[cfg(unix)]
+ use crate::api::schema::PaneListParams;
use crate::api::schema::{
- Method, PaneListParams, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
+ Method, PluginSourceInfo, PluginSourceKind, Request, SuccessResponse,
};
use std::time::{SystemTime, UNIX_EPOCH};