use std::cmp::min; use std::io::Read; #[cfg(feature = "local_fs")] use std::{env, fs, str}; use anyhow::Result; use base64::Engine; use flate2::read::ZlibDecoder; use galaxyui::image_cache::{resize_dimensions, FitType}; use galaxyui::{ assets::asset_cache::Asset, image_cache::{CustomHeaderCreationError, CustomImageFormat, CustomImageHeader, ImageType}, util::{parse_i32, parse_u32}, }; use pathfinder_geometry::vector::Vector2F; use rand::Rng; use galaxyui::assets::asset_cache::Asset; use galaxyui::image_cache::{ resize_dimensions, CustomHeaderCreationError, CustomImageFormat, CustomImageHeader, FitType, ImageType, }; use galaxyui::util::{parse_i32, parse_u32}; use super::escape_sequences::C1; /// Actions specified by the [Kitty Image Protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/) #[derive(Debug, Clone)] pub enum KittyAction { StoreOnly(StoreOnly), StoreAndDisplay(StoreAndDisplay), DisplayStoredImage(DisplayStoredImage), QuerySupport(QuerySupport), Delete { delete_placements_only: bool, deletion_type: DeletionType, }, } #[derive(Debug, Default, Clone)] pub struct StoreOnly { pub image: KittyImage, pub image_id: u32, } #[derive(Debug, Default, Clone)] pub struct StoreAndDisplay { pub image: KittyImage, pub placement_data: KittyPlacementData, pub image_id: u32, pub placement_id: u32, } #[derive(Debug, Default, Clone)] pub struct DisplayStoredImage { pub placement_data: KittyPlacementData, pub image_id: u32, pub placement_id: u32, } #[derive(Debug, Default, Clone)] pub struct QuerySupport { pub image: KittyImage, pub image_id: u32, } #[derive(Debug, Clone)] pub enum DeletionType { DeleteAll, DeleteById(DeleteById), } #[derive(Debug, Clone)] pub struct DeleteById { pub image_id: u32, pub placement_id: Option, } #[derive(Debug, Default, Clone)] pub struct KittyImage { pub metadata: KittyImageMetadata, pub data: Vec, } impl TryFrom for KittyMessage { type Error = InvalidKittyPayload; fn try_from(pending: PendingKittyMessage) -> Result { let mut decoded_payload = vec![]; for payload in pending.payload { decoded_payload.extend(base64_decode_padding_agnostic(&payload[..])?); } Ok(KittyMessage { control_data: pending.control_data, payload: decoded_payload, }) } } impl TryFrom for KittyImage { type Error = InvalidKittyPayload; fn try_from(message: KittyMessage) -> Result { let decoded_data = decode_kitty_image_data(message.payload, message.control_data.compressed)?; let decoded_data = match message.control_data.transmission_medium { KittyTransmissionMedium::Direct => decoded_data, KittyTransmissionMedium::SimpleFile => { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { read_file(decoded_data, false)? } else { return Err(InvalidKittyPayload::FileError(FileError::UnsupportedPlatform)); } } } KittyTransmissionMedium::TemporaryFile => { cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { read_file(decoded_data, true)? } else { return Err(InvalidKittyPayload::FileError(FileError::UnsupportedPlatform)); } } } KittyTransmissionMedium::SharedMemoryObject => { cfg_if::cfg_if! { if #[cfg(all(feature = "local_fs", unix))] { read_shared_memory(message.control_data.clone(), decoded_data)? } else { return Err(InvalidKittyPayload::ShmError(ShmError::UnsupportedPlatform)); } } } }; Ok(Self { metadata: KittyImageMetadata::from(message.control_data), data: decoded_data, }) } } pub type KittyResponse = Result<(), KittyError>; #[derive(Debug, Clone)] pub enum KittyError { InvalidKittyAction(InvalidKittyAction), StorageError(StorageError), KittyFeatureDisabled, } #[derive(Debug, Clone)] pub enum StorageError { UnknownId { id: u32 }, } impl From for KittyError { fn from(value: StorageError) -> Self { KittyError::StorageError(value) } } #[derive(Debug, Clone)] pub enum InvalidKittyAction { UnsupportedAction, InvalidKittyPayload(InvalidKittyPayload), InvalidControlData(InvalidControlData), } impl From for KittyError { fn from(value: InvalidKittyAction) -> Self { KittyError::InvalidKittyAction(value) } } #[derive(Debug, Clone)] pub enum InvalidControlData { IdMissing, UnicodePlaceholderUnsupported, } impl From for KittyError { fn from(value: InvalidControlData) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidControlData(value)) } } #[derive(Debug, Clone)] pub enum InvalidKittyPayload { InvalidTransmissionMedium(KittyTransmissionMedium), KittyDecodeError(KittyDecodeError), InvalidKittyImage(InvalidKittyImage), FileError(FileError), ShmError(ShmError), } #[derive(Debug, Clone)] pub enum FileError { FileReadError(String), UnsupportedPlatform, } impl From for KittyError { fn from(value: InvalidKittyPayload) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload(value)) } } #[derive(Debug, Clone)] pub enum ShmError { ObjectOpenError(String), MmapError, EmptyObject, ObjectTooSmall { expected_bytes: usize, actual_bytes: usize, }, InvalidObjectSize, FileStatError(String), UnsupportedPlatform, } #[derive(Debug, Clone)] pub enum KittyDecodeError { InvalidBase64(String), InvalidCompression(String), InvalidUtf8(String), } impl From for InvalidKittyPayload { fn from(value: KittyDecodeError) -> Self { InvalidKittyPayload::KittyDecodeError(value) } } impl From for KittyError { fn from(value: KittyDecodeError) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload( InvalidKittyPayload::KittyDecodeError(value), )) } } #[derive(Debug, Clone)] pub enum InvalidKittyImage { KittyPngError(KittyPngError), KittyRgbError(KittyRgbError), MalformedImage, } impl From for KittyError { fn from(value: InvalidKittyImage) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload( InvalidKittyPayload::InvalidKittyImage(value), )) } } #[derive(Debug, Clone)] pub enum KittyPngError { InvalidBytes(String), } impl From for KittyError { fn from(value: KittyPngError) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload( InvalidKittyPayload::InvalidKittyImage(InvalidKittyImage::KittyPngError(value)), )) } } #[derive(Debug, Clone)] pub enum KittyRgbError { ExpectedDataSizeMismatch { expected_bytes: usize, actual_bytes: usize, }, } impl From for KittyError { fn from(value: KittyRgbError) -> Self { KittyError::InvalidKittyAction(InvalidKittyAction::InvalidKittyPayload( InvalidKittyPayload::InvalidKittyImage(InvalidKittyImage::KittyRgbError(value)), )) } } impl From for KittyRgbError { fn from(value: CustomHeaderCreationError) -> Self { match value { CustomHeaderCreationError::ExpectedDataSizeMismatch { expected_bytes, actual_bytes, } => KittyRgbError::ExpectedDataSizeMismatch { expected_bytes, actual_bytes, }, } } } impl TryFrom for KittyAction { type Error = KittyError; fn try_from(message: KittyMessage) -> Result { match message.control_data.placement_action { KittyPlacementAction::StoreOnly => { let mut action = StoreOnly { image_id: message .control_data .image_id .unwrap_or(rand::thread_rng().gen()), image: KittyImage::try_from(message)?, }; if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png { action.image = set_kitty_png_size(action.image)?; } else { action.image = set_kitty_rgb_headers(action.image)?; } Ok(KittyAction::StoreOnly(action)) } KittyPlacementAction::StoreAndDisplay => { if message.control_data.unicode_placeholder { return Err(InvalidControlData::UnicodePlaceholderUnsupported.into()); } let mut action = StoreAndDisplay { image_id: message .control_data .image_id .unwrap_or(rand::thread_rng().gen()), placement_id: message .control_data .placement_id .unwrap_or(rand::thread_rng().gen()), placement_data: KittyPlacementData { z_index: message.control_data.z_index, cols: message.control_data.cols, rows: message.control_data.rows, cursor_movement_policy: message.control_data.cursor_movement_policy, }, image: KittyImage::try_from(message)?, }; if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png { action.image = set_kitty_png_size(action.image)?; } else { action.image = set_kitty_rgb_headers(action.image)?; } Ok(KittyAction::StoreAndDisplay(action)) } KittyPlacementAction::DisplayStoredImage => { if message.control_data.unicode_placeholder { return Err(InvalidControlData::UnicodePlaceholderUnsupported.into()); } let id = match message.control_data.image_id { Some(id) => id, None => return Err(InvalidControlData::IdMissing.into()), }; Ok(KittyAction::DisplayStoredImage(DisplayStoredImage { image_id: id, placement_id: message .control_data .placement_id .unwrap_or(rand::thread_rng().gen()), placement_data: KittyPlacementData { z_index: message.control_data.z_index, cols: message.control_data.cols, rows: message.control_data.rows, cursor_movement_policy: message.control_data.cursor_movement_policy, }, })) } KittyPlacementAction::QuerySupport => { let mut action = QuerySupport { image_id: message .control_data .image_id .unwrap_or(rand::thread_rng().gen()), image: KittyImage::try_from(message)?, }; if action.image.metadata.pixel_data_format == KittyPixelDataFormat::Png { action.image = set_kitty_png_size(action.image)?; } else { action.image = set_kitty_rgb_headers(action.image)?; } Ok(KittyAction::QuerySupport(action)) } KittyPlacementAction::Delete => { let deletion_type = match message.control_data.delete_action { DeleteAction::DeleteAll => DeletionType::DeleteAll, DeleteAction::DeleteById => { let image_id = match message.control_data.image_id { Some(image_id) => image_id, None => return Err(InvalidControlData::IdMissing.into()), }; DeletionType::DeleteById(DeleteById { image_id, placement_id: message.control_data.placement_id, }) } }; Ok(KittyAction::Delete { deletion_type, delete_placements_only: message.control_data.delete_placements_only, }) } KittyPlacementAction::Unknown => Err(InvalidKittyAction::UnsupportedAction.into()), } } } #[derive(Debug, Default, Clone)] pub struct KittyImageMetadata { pub pixel_data_format: KittyPixelDataFormat, pub transmission_medium: KittyTransmissionMedium, pub image_size: Vector2F, } #[derive(Debug, Default, Clone)] pub struct KittyPlacementData { pub z_index: i32, pub cols: Option, pub rows: Option, pub cursor_movement_policy: CursorMovementPolicy, } impl KittyPlacementData { pub fn get_desired_dimensions( &self, image_size: Vector2F, cell_height: usize, cell_width: usize, max_width_px: usize, max_height_px: usize, ) -> Vector2F { let aspect_ratio = image_size.x() / image_size.y(); let (width_px, height_px) = resize_dimensions( image_size.x() as u32, image_size.y() as u32, min(image_size.x() as u32, max_width_px as u32), min(image_size.y() as u32, max_height_px as u32), FitType::Contain, ); let (desired_width_px, desired_height_px) = match (self.cols, self.rows) { (Some(width), None) => { let desired_width_px = ((cell_width as u32) * width) as f32; (desired_width_px, desired_width_px / aspect_ratio) } (None, Some(height)) => { let desired_height_px = ((cell_height as u32) * height) as f32; (desired_height_px * aspect_ratio, desired_height_px) } (Some(width), Some(height)) => ( ((cell_width as u32) * width) as f32, ((cell_height as u32) * height) as f32, ), (None, None) => (width_px as f32, height_px as f32), }; Vector2F::new(desired_width_px, desired_height_px) } } impl From for KittyImageMetadata { fn from(control_data: KittyControlData) -> Self { Self { pixel_data_format: control_data.pixel_data_format, image_size: Vector2F::new(control_data.width as f32, control_data.height as f32), transmission_medium: control_data.transmission_medium, } } } #[derive(Debug, Clone)] pub struct KittyChunk { pub control_data: KittyControlData, pub payload: Vec, } #[derive(Debug, Clone)] pub struct PendingKittyMessage { pub control_data: KittyControlData, pub payload: Vec>, } #[derive(Debug, Clone)] pub struct KittyMessage { pub control_data: KittyControlData, pub payload: Vec, } #[derive(Default, Debug, Clone)] pub enum KittyPlacementAction { #[default] StoreOnly, StoreAndDisplay, DisplayStoredImage, QuerySupport, Delete, Unknown, } #[derive(Debug, Clone)] pub struct KittyControlData { pixel_data_format: KittyPixelDataFormat, width: u32, height: u32, pub compressed: bool, transmission_medium: KittyTransmissionMedium, pub further_chunks: bool, pub image_id: Option, pub placement_id: Option, pub placement_action: KittyPlacementAction, pub verbosity: KittyResponseVerbosity, pub z_index: i32, pub delete_action: DeleteAction, delete_placements_only: bool, pub rows: Option, pub cols: Option, pub cursor_movement_policy: CursorMovementPolicy, pub unicode_placeholder: bool, } impl Default for KittyControlData { fn default() -> Self { Self { pixel_data_format: KittyPixelDataFormat::default(), width: 0, height: 0, compressed: false, transmission_medium: KittyTransmissionMedium::default(), further_chunks: false, image_id: None, placement_id: None, placement_action: KittyPlacementAction::default(), verbosity: KittyResponseVerbosity::default(), z_index: 0, delete_action: DeleteAction::default(), delete_placements_only: true, rows: None, cols: None, cursor_movement_policy: CursorMovementPolicy::default(), unicode_placeholder: false, } } } #[derive(Default, Debug, PartialEq, Clone, Copy)] pub enum DeleteAction { #[default] DeleteAll, DeleteById, } #[derive(Default, Debug, PartialEq, Clone, Copy)] pub enum KittyPixelDataFormat { Rgb24Bit, #[default] Rgba32Bit, Png, } #[derive(Default, Debug, PartialEq, Clone, Copy)] pub enum KittyTransmissionMedium { #[default] Direct, SimpleFile, TemporaryFile, SharedMemoryObject, } #[derive(Default, Debug, Clone, Copy)] pub enum KittyResponseVerbosity { #[default] All, ErrorsOnly, None, } impl KittyResponseVerbosity { pub fn send_ok(&self) -> bool { matches!(self, KittyResponseVerbosity::All) } pub fn send_error(&self) -> bool { matches!(self, KittyResponseVerbosity::All) || matches!(self, KittyResponseVerbosity::ErrorsOnly) } } #[derive(Default, Debug, Clone, Copy)] pub enum CursorMovementPolicy { #[default] MoveCursor, DoNotMoveCursor, } fn parse_kitty_control_data(control_data: &[u8]) -> KittyControlData { let params: Vec<&[u8]> = control_data.split(|&byte| byte == b',').collect(); let mut parsed_control_data = KittyControlData::default(); for param in params { let (key, value) = match param.iter().position(|&byte| byte == b'=') { Some(position) => (¶m[..position], ¶m[position + 1..]), None => continue, }; match key { b"f" => { parsed_control_data.pixel_data_format = match value { b"24" => KittyPixelDataFormat::Rgb24Bit, b"32" => KittyPixelDataFormat::Rgba32Bit, b"100" => KittyPixelDataFormat::Png, _ => KittyPixelDataFormat::default(), }; } b"s" => { if let Some(value) = parse_u32(value) { parsed_control_data.width = value; } } b"v" => { if let Some(value) = parse_u32(value) { parsed_control_data.height = value; } } b"o" => { parsed_control_data.compressed = value == b"z"; } b"m" => { parsed_control_data.further_chunks = value == b"1"; } b"t" => { parsed_control_data.transmission_medium = match value { b"d" => KittyTransmissionMedium::Direct, b"f" => KittyTransmissionMedium::SimpleFile, b"t" => KittyTransmissionMedium::TemporaryFile, b"s" => KittyTransmissionMedium::SharedMemoryObject, _ => KittyTransmissionMedium::default(), } } b"i" => { if let Some(value) = parse_u32(value) { parsed_control_data.image_id = Some(value); } } b"p" => { if let Some(value) = parse_u32(value) { parsed_control_data.placement_id = Some(value); } } b"a" => { parsed_control_data.placement_action = match value { b"t" => KittyPlacementAction::StoreOnly, b"T" => KittyPlacementAction::StoreAndDisplay, b"p" => KittyPlacementAction::DisplayStoredImage, b"q" => KittyPlacementAction::QuerySupport, b"d" => KittyPlacementAction::Delete, _ => KittyPlacementAction::Unknown, } } b"q" => { parsed_control_data.verbosity = match value { b"0" => KittyResponseVerbosity::All, b"1" => KittyResponseVerbosity::ErrorsOnly, b"2" => KittyResponseVerbosity::None, _ => KittyResponseVerbosity::default(), } } b"z" => { if let Some(value) = parse_i32(value) { parsed_control_data.z_index = value; } } b"d" => { if value.iter().all(|x| x.is_ascii_uppercase()) { parsed_control_data.delete_placements_only = false; } parsed_control_data.delete_action = match &value.to_ascii_lowercase()[..] { // Note: Kitty Protocol specifies "a" as "Delete all placements on screen", // we're just starting with DeleteAll placements to expedite the launch. b"a" => DeleteAction::DeleteAll, b"i" => DeleteAction::DeleteById, _ => DeleteAction::default(), } } b"c" => { if let Some(value) = parse_u32(value) { parsed_control_data.cols = Some(value); } } b"r" => { if let Some(value) = parse_u32(value) { parsed_control_data.rows = Some(value); } } b"C" => { parsed_control_data.cursor_movement_policy = match value { b"0" => CursorMovementPolicy::MoveCursor, b"1" => CursorMovementPolicy::DoNotMoveCursor, _ => CursorMovementPolicy::default(), } } b"U" => { parsed_control_data.unicode_placeholder = value == b"1"; } _ => {} } } parsed_control_data } pub fn parse_kitty_chunk(chunk: Vec) -> KittyChunk { let (control_data, payload) = match chunk.iter().position(|&byte| byte == b';') { Some(position) => (&chunk[..position], &chunk[position + 1..]), None => (&chunk[..], &vec![][..]), }; let control_data = parse_kitty_control_data(control_data); KittyChunk { control_data, payload: payload.to_vec(), } } #[cfg(feature = "local_fs")] fn read_file(decoded_payload: Vec, is_temp: bool) -> Result, InvalidKittyPayload> { let path = match str::from_utf8(&decoded_payload[..]) { Ok(path) => path, Err(err) => return Err(KittyDecodeError::InvalidUtf8(err.to_string()).into()), }; let data = match fs::read(path) { Ok(data) => data, Err(err) => { return Err(InvalidKittyPayload::FileError(FileError::FileReadError( err.to_string(), ))) } }; if is_temp { safe_delete_temp_file(path); } Ok(data) } #[cfg(feature = "local_fs")] fn safe_delete_temp_file(path: &str) { if is_path_in_temp_dir(path) && path.contains("tty-graphics-protocol") { if let Err(err) = fs::remove_file(path) { log::error!("Failed to delete kitty temporary file (path = {path}): {err}"); } } } #[cfg(feature = "local_fs")] fn is_path_in_temp_dir(path: &str) -> bool { let temp_dirs = vec!["/tmp", "/var/tmp", "/dev/shm"]; for temp_dir in temp_dirs { if path.starts_with(temp_dir) { return true; } } if let Ok(temp_dir) = env::var("TMPDIR") { if path.starts_with(&temp_dir) { return true; } } false } #[cfg(all(feature = "local_fs", unix))] fn read_shared_memory( control_data: KittyControlData, decoded_payload: Vec, ) -> Result, InvalidKittyPayload> { use nix::sys::mman::{shm_open, shm_unlink}; let path = match str::from_utf8(&decoded_payload[..]) { Ok(path) => path, Err(err) => return Err(KittyDecodeError::InvalidUtf8(err.to_string()).into()), }; let fd = match shm_open( path, nix::fcntl::OFlag::O_RDONLY, nix::sys::stat::Mode::empty(), ) { Ok(fd) => fd, Err(err) => { return Err(InvalidKittyPayload::ShmError(ShmError::ObjectOpenError( err.to_string(), ))) } }; let bytes_per_pixel = match control_data.pixel_data_format { KittyPixelDataFormat::Rgb24Bit => Some(3), KittyPixelDataFormat::Rgba32Bit => Some(4), KittyPixelDataFormat::Png => None, }; let size = bytes_per_pixel.map(|bytes_per_pixel| { (bytes_per_pixel * control_data.width * control_data.height) as usize }); let data = read_from_shared_memory_fd(fd, size); if let Err(err) = shm_unlink(path) { log::warn!("Failed to unlink kitty shm file (path = {path}): {err:?}"); }; data } #[cfg(all(feature = "local_fs", unix))] fn read_from_shared_memory_fd( fd: i32, size: Option, ) -> Result, InvalidKittyPayload> { use std::num::NonZero; use nix::sys::mman::{mmap, MapFlags, ProtFlags}; use nix::sys::stat::fstat; let file_size = match fstat(fd) { Ok(stat) => stat.st_size, Err(err) => { return Err(InvalidKittyPayload::ShmError(ShmError::FileStatError( err.to_string(), ))) } }; let Ok(file_size) = usize::try_from(file_size) else { return Err(InvalidKittyPayload::ShmError(ShmError::InvalidObjectSize)); }; let size = size.unwrap_or(file_size); if file_size < size { return Err(InvalidKittyPayload::ShmError(ShmError::ObjectTooSmall { expected_bytes: size, actual_bytes: file_size, })); } let Some(size) = NonZero::new(size) else { return Err(InvalidKittyPayload::ShmError(ShmError::EmptyObject)); }; let ptr = unsafe { mmap( None, size, ProtFlags::PROT_READ, MapFlags::MAP_SHARED, fd, 0, ) }; let Ok(ptr) = ptr else { return Err(InvalidKittyPayload::ShmError(ShmError::MmapError)); }; let slice = unsafe { std::slice::from_raw_parts(ptr as *const u8, size.into()) }; let data = slice.to_vec(); Ok(data) } fn base64_decode_padding_agnostic(data: &[u8]) -> Result, KittyDecodeError> { if let Ok(decoded_bytes) = base64::engine::general_purpose::STANDARD.decode(data) { return Ok(decoded_bytes); } match base64::engine::general_purpose::STANDARD_NO_PAD.decode(data) { Ok(decoded_bytes) => Ok(decoded_bytes), Err(err) => Err(KittyDecodeError::InvalidBase64(err.to_string())), } } pub fn decode_kitty_image_data( payload: Vec, compressed: bool, ) -> Result, KittyDecodeError> { let mut result = payload; if compressed { let mut decoder = ZlibDecoder::new(&result[..]); let mut decompresed_bytes = vec![]; match decoder.read_to_end(&mut decompresed_bytes) { Ok(_) => {} Err(err) => return Err(KittyDecodeError::InvalidCompression(err.to_string())), }; result = decompresed_bytes; } Ok(result) } pub fn set_kitty_png_size(mut image: KittyImage) -> Result { let image_type = match ImageType::try_from_bytes(&image.data[..]) { Ok(image_type) => image_type, Err(err) => return Err(KittyPngError::InvalidBytes(err.to_string())), }; let image_size = match image_type.image_size() { Some(image_size) => image_size, None => { return Err(KittyPngError::InvalidBytes( "Could not retrieve image size from ImageType for Kitty PNG.".to_string(), )) } }; image.metadata.image_size = image_size.to_f32(); Ok(image) } pub fn set_kitty_rgb_headers(mut image: KittyImage) -> Result { let image_format = match image.metadata.pixel_data_format { KittyPixelDataFormat::Rgb24Bit => CustomImageFormat::Rgb, KittyPixelDataFormat::Rgba32Bit => CustomImageFormat::Rgba, KittyPixelDataFormat::Png => return Ok(image), }; image.data = CustomImageHeader::prepend_custom_header( image.data, image.metadata.image_size.x() as u32, image.metadata.image_size.y() as u32, image_format, )?; Ok(image) } fn create_kitty_reply(image_id: u32, message: String) -> Vec { [ C1::APC, b"G", format!("i={image_id};{message}").as_bytes(), C1::ST, ] .concat() } pub fn create_kitty_ok_reply(image_id: u32) -> Vec { create_kitty_reply(image_id, "OK".to_string()) } pub fn create_kitty_error_reply(image_id: u32, err: KittyError) -> Vec { create_kitty_reply(image_id, format!("{err:?}")) }