diff --git a/Cargo.lock b/Cargo.lock index f51308b5..c79c2299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9715,6 +9715,7 @@ dependencies = [ "libgssapi", "log", "memchr", + "miniz_oxide", "notify 8.2.0", "portable-pty", "russh", diff --git a/crates/tty7-core/Cargo.toml b/crates/tty7-core/Cargo.toml index bc262270..b4a73620 100644 --- a/crates/tty7-core/Cargo.toml +++ b/crates/tty7-core/Cargo.toml @@ -112,6 +112,12 @@ memchr = "2" # roughly 4 MB. Base64 costs 1.33× instead. Already in the tree via russh. base64 = "0.22" +# zlib inflate for the kitty graphics protocol's `o=z` (compressed) pixel +# payloads (`core::kitty_graphics`). Pure-Rust, no new native code, and already +# in the tree transitively — the same crate `terminal-browser` uses for the +# deflate side, so the two ends agree on the wire format. +miniz_oxide = "0.8" + [target.'cfg(unix)'.dependencies] libc = "0.2" # GSSAPI/Kerberos SSH auth — see the `gssapi` feature below for why it is diff --git a/crates/tty7-core/src/core/kitty_graphics.rs b/crates/tty7-core/src/core/kitty_graphics.rs new file mode 100644 index 00000000..ff7fa7a5 --- /dev/null +++ b/crates/tty7-core/src/core/kitty_graphics.rs @@ -0,0 +1,1937 @@ +//! Streaming kitty-graphics-protocol extractor (APC `ESC _ G … ST`). +//! +//! The daemon-side counterpart to [`crate::core::osc`]: a tiny, resumable state +//! machine that pulls kitty graphics commands out of the raw PTY byte stream +//! *without* running a full VT parser. It exists for the same reason the OSC +//! sniffer does — the client's `alacritty_terminal` fork silently discards APC +//! sequences (`ESC _` routes to vte's `SosPmApcString`, whose payload bytes hit +//! a `_ => ()` arm), so an image transmitted this way never surfaces as a `Term` +//! event. We tap the bytes here instead. +//! +//! This lives daemon-side so the pixel payload can be lifted out of the stream +//! *before* it enters the replay ring — a full-window RGBA frame is hundreds of +//! KB, and letting it accumulate in the ring would make reattach replay +//! catastrophic. The daemon decodes here and forwards a compact out-of-band +//! frame to the client; the base64 text never rides the socket, and the VT +//! parser never has to chew through it. +//! +//! Scope: this handles the subset the wire actually carries in practice +//! (transmit-and-display, query, delete). Which transmission media we accept +//! depends on where the pane lives: +//! +//! - Direct (`t=d`, base64 inline) is always honored — it is the only medium +//! that survives tty7's socket + SSH topology. +//! - File (`t=f`/`t=t`) and shared memory (`t=s`) are honored only on a *local* +//! unix pane, where the name resolves on this host and the daemon can read it +//! without anything crossing a tunnel. Everywhere else — a remote pane, or a +//! non-unix host where [`MediumTransfer::resolve`] can't do the work — the +//! probe is answered *unsupported* so a sender like `terminal-browser` falls +//! back to `t=d` on its own rather than transmitting into a black hole. +//! +//! Protocol reference: + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde::{Deserialize, Serialize}; + +/// Cap on a single APC payload (one chunk) we'll buffer before abandoning it. +/// `terminal-browser` chunks direct transmissions into 4 KiB base64 pieces, but +/// the spec only *recommends* chunking — a sender is free to put a whole frame +/// in one command, and an uncompressed 4K RGBA frame is ~44 MB of base64. Size +/// this to admit a one-shot frame that still fits [`MAX_TRANSMISSION_BASE64`]; +/// [`ApcTokenizer::push_graphics`] logs whatever it has to drop. +const MAX_APC_PAYLOAD: usize = MAX_TRANSMISSION_BASE64; + +/// Cap on the reassembled base64 of one chunked transmission. +/// +/// This has to stay under [`crate::daemon::protocol::MAX_FRAME`] once decoded: +/// base64 shrinks by 3/4, and [`Image::encode_frame`] prepends [`HEADER_LEN`] +/// bytes, so the decoded payload must leave room for the header inside the wire +/// frame. Blowing past `MAX_FRAME` would make `write_frame` fail, which the +/// daemon's writer loop treats as fatal — one oversized image would drop the +/// client's whole connection instead of just that frame. 48 MiB of base64 is +/// ~36 MB of pixels, comfortably more than a 4K full-window RGBA frame (~33 MB) +/// and comfortably under the 64 MiB wire ceiling. +const MAX_TRANSMISSION_BASE64: usize = 48 << 20; // 48 MiB + +/// Cap on the *resolved* pixel bytes of one image, whatever medium carried it. +/// Bounds the file/shm fast path (where the sender names an object whose size we +/// don't control) and backstops the direct path, keeping every frame we hand the +/// daemon inside [`crate::daemon::protocol::MAX_FRAME`]. +pub const MAX_IMAGE_BYTES: usize = crate::daemon::protocol::MAX_FRAME - HEADER_LEN; + +/// A streaming tokenizer that splits raw output into a *passthrough* byte stream +/// and the kitty graphics commands lifted out of it. +/// +/// Feed it raw output bytes. It invokes `on_passthrough` with every byte that is +/// **not** part of a `_G` graphics sequence — that is the exact input with each +/// `ESC _ G … ST` removed, and it is what the daemon appends to the replay ring +/// and forwards to the client. It invokes `on_command` with the complete payload +/// of each `_G` command (the bytes after `ESC _`, terminator excluded — e.g. +/// `Ga=T,f=32,…;`). Non-graphics APC sequences (any payload not starting +/// with `G`) are passed through verbatim, since only kitty graphics is ours to +/// intercept; everything else must reach the client's VT parser unchanged. State +/// persists across `feed` calls, so a sequence split over several reads is still +/// handled. +pub struct ApcTokenizer { + /// Bytes of a `_G` command accumulated after `ESC _ G` while it can still + /// terminate. Cleared whenever a command finishes or is abandoned. + buf: Vec, + state: State, +} + +#[derive(Default, Clone, Copy)] +enum State { + /// Not inside an escape sequence; bytes are passthrough. + #[default] + Ground, + /// Held one `ESC` in ground state; a following `_` opens an APC. + Esc, + /// Held `ESC _`; the next byte decides graphics (`G`) vs. passthrough APC. + ApcStart, + /// Buffering a `_G` graphics command (stripped from passthrough). + ApcGraphics, + /// Saw `ESC` inside a graphics command — a following `\` is the terminator. + ApcGraphicsEsc, + /// Forwarding a non-graphics APC as passthrough (bytes kept verbatim). + PassApc, + /// Saw `ESC` inside a passthrough APC — a following `\` is the terminator. + PassApcEsc, + /// Dropping an abandoned/oversized graphics APC (emitted nowhere). + ApcDrop, + /// Saw `ESC` inside a dropped APC — a following `\` is the terminator. + ApcDropEsc, +} + +impl Default for ApcTokenizer { + fn default() -> Self { + Self::new() + } +} + +impl ApcTokenizer { + pub fn new() -> Self { + Self { + buf: Vec::new(), + state: State::Ground, + } + } + + /// Feed one chunk of output. `on_passthrough` receives runs of non-graphics + /// bytes (the input minus every `ESC _ G … ST`); `on_command` receives the + /// `G…` payload of each graphics command that terminates within the chunk. + /// + /// Like the OSC tokenizer, the states that dominate a real stream — `Ground` + /// between sequences, and the scan-to-terminator states inside an APC — skip + /// ahead with SIMD `memchr` rather than stepping per byte; only the few + /// escape-decision states step one byte at a time. APC has no `BEL` + /// terminator (kitty always closes with `ST`), so only `ESC` can end a + /// payload. A held `ESC`/`ESC _` that later proves to be passthrough is + /// re-emitted as a constant, so nothing needs to survive across `feed` calls + /// but the small state tag and the graphics buffer. + pub fn feed( + &mut self, + bytes: &[u8], + mut on_passthrough: impl FnMut(&[u8]), + mut on_command: impl FnMut(&[u8]), + ) { + let mut i = 0; + while i < bytes.len() { + match self.state { + State::Ground => match memchr::memchr(0x1b, &bytes[i..]) { + Some(off) => { + if off > 0 { + on_passthrough(&bytes[i..i + off]); + } + self.state = State::Esc; + i += off + 1; + } + None => { + on_passthrough(&bytes[i..]); + return; + } + }, + State::PassApc => match memchr::memchr(0x1b, &bytes[i..]) { + Some(off) => { + if off > 0 { + on_passthrough(&bytes[i..i + off]); + } + self.state = State::PassApcEsc; + i += off + 1; + } + None => { + on_passthrough(&bytes[i..]); + return; + } + }, + State::ApcDrop => match memchr::memchr(0x1b, &bytes[i..]) { + Some(off) => { + self.state = State::ApcDropEsc; + i += off + 1; + } + None => return, + }, + State::ApcGraphics => match memchr::memchr(0x1b, &bytes[i..]) { + Some(off) => { + if self.push_graphics(&bytes[i..i + off]) { + self.state = State::ApcGraphicsEsc; + i += off + 1; + } else { + // Oversized: abandon and let the drop state consume + // the terminator we just found (leave `i` on the ESC). + self.state = State::ApcDrop; + i += off; + } + } + None => { + if !self.push_graphics(&bytes[i..]) { + self.state = State::ApcDrop; + } + return; + } + }, + State::Esc => match bytes[i] { + b'_' => { + self.buf.clear(); + self.state = State::ApcStart; + i += 1; + } + // A run of ESCs: the earlier one was a lone ESC (passthrough); + // keep the newest one held. + 0x1b => { + on_passthrough(b"\x1b"); + i += 1; + } + // The ESC began some other escape: emit it and re-examine this + // byte from ground (it is not an ESC, so it joins the run). + _ => { + on_passthrough(b"\x1b"); + self.state = State::Ground; + } + }, + State::ApcStart => { + if bytes[i] == b'G' { + self.buf.clear(); + self.buf.push(b'G'); + self.state = State::ApcGraphics; + i += 1; + } else { + // Not ours: emit the held `ESC _` and forward the rest of + // the APC verbatim (re-examine this byte in `PassApc`). + on_passthrough(b"\x1b_"); + self.state = State::PassApc; + } + } + State::ApcGraphicsEsc => match bytes[i] { + b'\\' => { + on_command(&self.buf); + self.buf.clear(); + self.state = State::Ground; + i += 1; + } + 0x1b => i += 1, // a run of ESCs; stay poised for `\` + // A bare `ESC _` re-opens: the next byte decides graphics again. + b'_' => { + self.buf.clear(); + self.state = State::ApcStart; + i += 1; + } + // ESC began some other escape: abandon this graphics command. + // Its bytes were meant as graphics, so they stay stripped — + // but the escape itself belongs to the terminal, so forward + // it and re-examine this byte from Ground rather than eating + // both. Swallowing them would turn a `\x1b[31m` that follows + // an unterminated graphics command into literal `31m`. + _ => { + self.buf.clear(); + on_passthrough(b"\x1b"); + self.state = State::Ground; + } + }, + State::PassApcEsc => match bytes[i] { + b'\\' => { + on_passthrough(b"\x1b\\"); // ST is part of the forwarded APC + self.state = State::Ground; + i += 1; + } + 0x1b => { + on_passthrough(b"\x1b"); // a lone ESC in APC data + i += 1; + } + // A bare `ESC _` re-opens, same as the two graphics states + // do. Without this, a foreign APC that never sends its ST + // swallows every `ESC _G …` after it — the graphics get + // forwarded as APC text that the client's vte then discards, + // so the image is simply lost. + b'_' => { + self.buf.clear(); + self.state = State::ApcStart; + i += 1; + } + _ => { + on_passthrough(b"\x1b"); + self.state = State::PassApc; // re-examine this byte + } + }, + State::ApcDropEsc => match bytes[i] { + b'\\' => { + self.state = State::Ground; + i += 1; + } + 0x1b => i += 1, + b'_' => { + self.buf.clear(); + self.state = State::ApcStart; + i += 1; + } + // As in `ApcGraphicsEsc`: the dropped command's bytes stay + // stripped, but the escape that interrupted it is the + // terminal's and has to reach the client intact. + _ => { + on_passthrough(b"\x1b"); + self.state = State::Ground; + } + }, + } + } + } + + /// Append a run to the graphics buffer; returns `false` (and clears the + /// buffer) if it would exceed [`MAX_APC_PAYLOAD`], signalling the caller to + /// abandon the command. + fn push_graphics(&mut self, run: &[u8]) -> bool { + if self.buf.len() + run.len() > MAX_APC_PAYLOAD { + log::debug!( + "kitty graphics: dropping a command whose payload passed {MAX_APC_PAYLOAD} bytes" + ); + self.buf.clear(); + return false; + } + self.buf.extend_from_slice(run); + true + } +} + +/// Daemon-side splitter: the [`ApcTokenizer`] wired to a [`GraphicsParser`]. +/// +/// This is the one type the PTY reader loop touches. Feed it raw output and a +/// passthrough sink; it strips every kitty graphics sequence, funnels the +/// passthrough bytes to the sink (for the replay ring and the client), and +/// returns the decoded graphics [`Event`]s — query replies to write back to the +/// PTY, and images to forward out-of-band. +#[derive(Default)] +pub struct GraphicsSniffer { + tokenizer: ApcTokenizer, + parser: GraphicsParser, +} + +impl GraphicsSniffer { + pub fn new() -> Self { + Self::default() + } + + /// A sniffer whose parser may honor file/shm transfer (local pane only). + /// See [`GraphicsParser::new_local`]. + pub fn new_local(local: bool) -> Self { + Self { + tokenizer: ApcTokenizer::new(), + parser: GraphicsParser::new_local(local), + } + } + + /// Update whether the sender currently shares this host's filesystem. A pane + /// is local when tty7 spawned a local shell and no foreground `ssh` owns the + /// PTY; that can flip mid-session, and it gates whether the next `a=q` probe + /// is answered `OK` for file/shm transfer. Cheap enough to call per chunk. + pub fn set_local(&mut self, local: bool) { + self.parser.local = local; + } + + /// Feed one chunk of raw PTY output. `on_passthrough` receives the byte + /// stream with all graphics sequences removed; the returned events are the + /// queries/images/deletes that completed within this chunk, in order. + /// + /// This drops the *relative order* of passthrough vs. events; for the daemon + /// loop, prefer [`sniff`](Self::sniff), which preserves it. Retained as the + /// low-level primitive the unit tests drive. + pub fn feed(&mut self, bytes: &[u8], on_passthrough: impl FnMut(&[u8])) -> Vec { + let Self { tokenizer, parser } = self; + let mut events = Vec::new(); + tokenizer.feed(bytes, on_passthrough, |cmd| { + if let Some(ev) = parser.feed(cmd) { + events.push(ev); + } + }); + events + } + + /// Feed a chunk and get back its content **in stream order**, taking a + /// zero-copy fast path on the overwhelmingly common chunk that carries no + /// graphics at all. + /// + /// Order matters: a kitty image is placed at the cursor cell as it stood + /// *when its command appeared in the stream*, so the client must apply the + /// text before an image before resolving that image's anchor. Returning + /// ordered [`Segment`]s lets the daemon forward `Output`/`Image` frames + /// interleaved exactly as they arrived, so the client's mirror cursor is + /// correct at each placement. + /// + /// The fast path fires when the tokenizer is between sequences *and* the + /// chunk holds no APC opener (`ESC _`) — then the input **is** one big + /// output segment, returned borrowed with no allocation. A build's worth of + /// colored stdout (full of CSI escapes but no APC) stays on this path. The + /// trailing-`ESC` guard keeps an `ESC _` straddling a chunk boundary on the + /// slow path, where the held-`ESC` state carries it across correctly. + pub fn sniff<'a>(&mut self, bytes: &'a [u8]) -> Sniffed<'a> { + if matches!(self.tokenizer.state, State::Ground) + && bytes.last() != Some(&0x1b) + && memchr::memmem::find(bytes, b"\x1b_").is_none() + { + return Sniffed::Plain(bytes); + } + let Self { tokenizer, parser } = self; + // Both tokenizer callbacks push onto the same segment list; `RefCell` + // lets the passthrough and command sinks share it without either taking + // a lasting mutable borrow the borrow checker would reject. + let segments: std::cell::RefCell> = std::cell::RefCell::new(Vec::new()); + tokenizer.feed( + bytes, + |run| { + // Coalesce adjacent passthrough runs into one output segment so + // the client applies them in a single `advance`. + let mut segs = segments.borrow_mut(); + if let Some(Segment::Output(buf)) = segs.last_mut() { + buf.extend_from_slice(run); + } else { + segs.push(Segment::Output(run.to_vec())); + } + }, + |cmd| { + if let Some(ev) = parser.feed(cmd) { + segments.borrow_mut().push(Segment::from(ev)); + } + }, + ); + Sniffed::Segments(segments.into_inner()) + } +} + +/// One ordered piece of a sniffed chunk — see [`GraphicsSniffer::sniff`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Segment { + /// Non-graphics bytes to append to the ring / forward as `Output`. + Output(Vec), + /// An `a=q` reply to write back to the PTY. + Query(Vec), + /// An image to forward out-of-band. + Image(Image), + /// A file/shm transmission the daemon must resolve into pixels before + /// forwarding (local panes only). See [`MediumTransfer`]. + ImageFromMedium(MediumTransfer), + /// A delete to forward out-of-band. + Delete(ImageDelete), +} + +impl From for Segment { + fn from(ev: Event) -> Self { + match ev { + Event::Query { reply, .. } => Segment::Query(reply), + Event::Image(img) => Segment::Image(img), + Event::ImageFromMedium(t) => Segment::ImageFromMedium(t), + Event::Delete(c) => Segment::Delete(ImageDelete::from_control(&c)), + } + } +} + +/// The result of [`GraphicsSniffer::sniff`]: either the whole chunk borrowed as +/// output (the graphics-free fast path), or ordered [`Segment`]s. +pub enum Sniffed<'a> { + /// No graphics in this chunk: the input is output, verbatim and borrowed. + Plain(&'a [u8]), + /// Graphics present: apply these in order. + Segments(Vec), +} + +/// The kitty action key (`a=`). Only the variants tty7 acts on are named; the +/// rest of the protocol (frames, animation, compose) folds into `Other`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + /// `a=q` — query whether a transmission would succeed. We must reply. + Query, + /// `a=t` — transmit only (no display). Rare from our target sender. + Transmit, + /// `a=T` — transmit and display. + TransmitAndDisplay, + /// `a=p` — display a previously transmitted image. + Display, + /// `a=d` — delete image(s)/placement(s). + Delete, + /// Any action we don't specifically handle. + Other, +} + +/// The pixel format key (`f=`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WireFormat { + /// `f=24` — 3 bytes/pixel RGB. + Rgb, + /// `f=32` — 4 bytes/pixel RGBA (the default, and what `terminal-browser` uses). + Rgba, + /// `f=100` — a PNG file; pixels stay encoded (decoded client-side). + Png, +} + +/// The transmission medium key (`t=`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Medium { + /// `t=d` — direct: the payload is (base64) the image data itself. + Direct, + /// `t=f` — a filesystem path to the data. + File, + /// `t=s` — a POSIX shared-memory object name. + Shared, + /// `t=t` — a temporary file (deleted after reading). + TempFile, +} + +/// Parsed kitty graphics control keys (the `k=v,k=v` list before the `;`). +/// +/// Only the keys tty7 needs are surfaced; unknown keys are ignored so forward +/// protocol additions degrade quietly rather than failing the whole command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Control { + pub action: Action, + pub format: WireFormat, + pub medium: Medium, + /// `o=z` — payload is zlib-compressed. + pub compressed: bool, + /// `i=` — client-assigned image id (0 = unset). + pub id: u32, + /// `I=` — client-assigned image number (0 = unset). + pub number: u32, + /// `p=` — placement id (0 = unset). + pub placement: u32, + /// `s=` — source pixel width. + pub width: u32, + /// `v=` — source pixel height. + pub height: u32, + /// `c=` — columns to display across (0 = derive from pixels). + pub cols: u32, + /// `r=` — rows to display down (0 = derive from pixels). + pub rows: u32, + /// `m=1` — more chunks follow. + pub more: bool, + /// `d=` — delete target (only meaningful for `a=d`). + pub delete: u8, + /// `q=` — suppress responses (1 = errors only, 2 = all). + pub quiet: u8, + /// `O=` — byte offset into a file/shm object (file/shm mediums only). + pub offset: u32, + /// `S=` — number of bytes to read from a file/shm object (0 = to end). + pub size: u32, +} + +impl Default for Control { + fn default() -> Self { + // Protocol defaults: a=t, f=32, t=d, no compression. + Self { + action: Action::Transmit, + format: WireFormat::Rgba, + medium: Medium::Direct, + compressed: false, + id: 0, + number: 0, + placement: 0, + width: 0, + height: 0, + cols: 0, + rows: 0, + more: false, + delete: 0, + quiet: 0, + offset: 0, + size: 0, + } + } +} + +impl Control { + /// Parse the control section of a `_G` command — the `G…` payload up to (but + /// not including) the first `;`. The leading `G` is part of the first key + /// name's position, i.e. the payload is `Ga=T,f=32,…;`; we accept the + /// whole `G…;…` slice and split on the `;`. + pub fn parse(payload: &[u8]) -> Option { + // Strip the leading `G` identifier. + let rest = payload.strip_prefix(b"G")?; + let control = match rest.iter().position(|&b| b == b';') { + Some(pos) => &rest[..pos], + None => rest, // no payload section (e.g. `a=d`) + }; + let mut c = Control::default(); + for pair in control.split(|&b| b == b',') { + if pair.is_empty() { + continue; + } + let mut kv = pair.splitn(2, |&b| b == b'='); + let key = kv.next().unwrap_or(b""); + let val = kv.next().unwrap_or(b""); + let num = || parse_u32(val); + match key { + b"a" => { + c.action = match val { + b"q" => Action::Query, + b"t" => Action::Transmit, + b"T" => Action::TransmitAndDisplay, + b"p" => Action::Display, + b"d" => Action::Delete, + _ => Action::Other, + } + } + b"f" => { + c.format = match val { + b"24" => WireFormat::Rgb, + b"100" => WireFormat::Png, + _ => WireFormat::Rgba, // 32 and the default + } + } + b"t" => { + c.medium = match val { + b"f" => Medium::File, + b"s" => Medium::Shared, + b"t" => Medium::TempFile, + _ => Medium::Direct, + } + } + b"o" => c.compressed = val == b"z", + b"i" => c.id = num(), + b"I" => c.number = num(), + b"p" => c.placement = num(), + b"s" => c.width = num(), + b"v" => c.height = num(), + b"c" => c.cols = num(), + b"r" => c.rows = num(), + b"m" => c.more = val == b"1", + b"d" => c.delete = val.first().copied().unwrap_or(0), + // Clamp rather than truncate: `q=256` must not wrap to 0 and + // turn a request for silence into a request for chatter. + b"q" => c.quiet = num().min(u8::MAX as u32) as u8, + b"O" => c.offset = num(), + b"S" => c.size = num(), + _ => {} // unknown key: ignore + } + } + Some(c) + } +} + +fn parse_u32(bytes: &[u8]) -> u32 { + let mut n: u32 = 0; + for &b in bytes { + if !b.is_ascii_digit() { + return 0; + } + n = n.saturating_mul(10).saturating_add((b - b'0') as u32); + } + n +} + +/// The `;`-separated data section of a `_G` command (may be empty). +fn payload_data(command: &[u8]) -> &[u8] { + match command.iter().position(|&b| b == b';') { + Some(pos) => &command[pos + 1..], + None => &[], + } +} + +/// A reassembled image transmission, base64-decoded but *not yet* inflated. +/// +/// The daemon does only the base64 decode (cheap, and it strips the 33% text +/// inflation) and forwards this over the socket; the client calls [`to_rgba8`] +/// to inflate and normalize to RGBA8. Keeping [`data`] compressed on the wire is +/// what makes a *remote* pane viable — inflating daemon-side would push tens of +/// MB per frame back through the SSH tunnel. +/// +/// [`data`]: Image::data +/// [`to_rgba8`]: Image::to_rgba8 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Image { + pub id: u32, + pub number: u32, + pub placement: u32, + /// Source pixel dimensions. + pub width: u32, + pub height: u32, + /// Requested display span in cells (0 = derive from pixels / cell size). + pub cols: u32, + pub rows: u32, + /// The base64-decoded payload: zlib-compressed pixels when [`compressed`], + /// otherwise raw pixels ([`WireFormat::Rgb`]/[`WireFormat::Rgba`]) or an + /// encoded PNG file ([`WireFormat::Png`]). + /// + /// [`compressed`]: Image::compressed + pub data: Vec, + pub format: WireFormat, + /// Whether [`data`](Image::data) is still zlib-compressed (`o=z`). + pub compressed: bool, +} + +impl Image { + /// Inflate and normalize to tightly packed RGBA8 (`width*height*4`), the + /// form the renderer wants. Runs client-side. Returns `None` for a PNG + /// (whose decode needs the `image` crate the GUI owns, not this core) or on + /// a malformed payload; PNG callers should decode [`data`](Image::data) + /// themselves after checking [`format`](Image::format). + /// + /// The inflate is bounded by what `width`/`height` claim the pixels are: + /// deflate expands ~1000:1 in the limit, so an unbounded `decompress_to_vec` + /// here would let a payload well inside [`MAX_TRANSMISSION_BASE64`] balloon + /// into tens of GB and OOM the client — and this runs in the GUI process, so + /// it would take every pane down, not just the one that received the escape. + /// The caller checks the inflated length against `width*height*4` anyway, so + /// capping it up front costs nothing. + pub fn to_rgba8(&self) -> Option> { + if self.format == WireFormat::Png { + return None; + } + let raw = if self.compressed { + // Bound the inflate by the pixels the sender *claims* to be sending + // (`width * height` at this format's bytes-per-pixel). Without a cap, + // a high-ratio deflate payload well inside `MAX_TRANSMISSION_BASE64` + // (which bounds only the *compressed* bytes) inflates to tens of GB + // and OOMs the whole GUI process. A payload that decompresses past + // its own declared dimensions is malformed, so we drop it. + // + // The declared size is itself attacker-chosen, so clamp it too: + // `s=65535,v=65535` alone works out to 17 GB, which would hand the + // bomb right back its allocation. No real frame comes near + // `MAX_IMAGE_BYTES`, which is what the wire can carry anyway. + let limit = self.decoded_len()?.min(MAX_IMAGE_BYTES); + miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(&self.data, limit).ok()? + } else { + self.data.clone() + }; + Some(match self.format { + WireFormat::Rgb => rgb_to_rgba(&raw), + _ => raw, // Rgba + }) + } + + /// The byte length of this image's *decoded* (pre-`rgb_to_rgba`) pixels — + /// `width * height * bytes_per_pixel` for the wire format. `None` on overflow + /// or a zero-sized/PNG image, which have no fixed raw length. + fn decoded_len(&self) -> Option { + let bpp = match self.format { + WireFormat::Rgb => 3usize, + WireFormat::Rgba => 4usize, + WireFormat::Png => return None, + }; + (self.width as usize) + .checked_mul(self.height as usize)? + .checked_mul(bpp) + .filter(|&n| n != 0) + } + + /// Encode for the daemon→client [`crate::daemon::protocol::DaemonMsg::Image`] + /// frame: a fixed 30-byte header carrying the metadata, then the raw `data` + /// bytes appended verbatim. A JSON envelope would base64-inflate the pixel + /// payload ~1.33×; this keeps it byte-for-byte, which matters at video frame + /// rates. See [`decode_frame`](Image::decode_frame). + pub fn encode_frame(&self) -> Vec { + let mut out = Vec::with_capacity(HEADER_LEN + self.data.len()); + out.extend_from_slice(&self.id.to_le_bytes()); + out.extend_from_slice(&self.number.to_le_bytes()); + out.extend_from_slice(&self.placement.to_le_bytes()); + out.extend_from_slice(&self.width.to_le_bytes()); + out.extend_from_slice(&self.height.to_le_bytes()); + out.extend_from_slice(&self.cols.to_le_bytes()); + out.extend_from_slice(&self.rows.to_le_bytes()); + out.push(match self.format { + WireFormat::Rgb => 24, + WireFormat::Rgba => 32, + WireFormat::Png => 100, + }); + out.push(u8::from(self.compressed)); + out.extend_from_slice(&self.data); + out + } + + /// Reconstruct from an [`encode_frame`](Image::encode_frame) payload. + pub fn decode_frame(bytes: &[u8]) -> Option { + if bytes.len() < HEADER_LEN { + return None; + } + let u32_at = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap()); + let format = match bytes[28] { + 24 => WireFormat::Rgb, + 100 => WireFormat::Png, + _ => WireFormat::Rgba, + }; + Some(Image { + id: u32_at(0), + number: u32_at(4), + placement: u32_at(8), + width: u32_at(12), + height: u32_at(16), + cols: u32_at(20), + rows: u32_at(24), + format, + compressed: bytes[29] != 0, + data: bytes[HEADER_LEN..].to_vec(), + }) + } +} + +/// Byte length of the [`Image::encode_frame`] header: seven `u32` fields, then +/// the format and compression bytes. +const HEADER_LEN: usize = 7 * 4 + 1 + 1; + +impl MediumTransfer { + /// Resolve the referenced bytes into an [`Image`] by reading the file or + /// `mmap`ing the POSIX shm object this transfer names, then unlinking it (a + /// [`Medium::TempFile`] and any shm object are one-shot handoffs the sender + /// expects the terminal to consume and remove; a plain [`Medium::File`] is + /// left in place). Runs on the daemon reader thread of a *local* pane, where + /// the name resolves on this host. Returns `None` if the name is unusable or + /// the object can't be read. + /// + /// The pixels come back *uncompressed* unless the sender set `o=z`: a sender + /// that reaches for shared memory does so precisely to avoid the zlib the + /// inline path forces, so this is the fast path that skips the client-side + /// inflate entirely. + #[cfg(unix)] + pub fn resolve(&self) -> Option { + let data = match self.medium { + Medium::Shared => self.read_shared()?, + Medium::File | Medium::TempFile => self.read_file()?, + // Direct never becomes a MediumTransfer. + Medium::Direct => return None, + }; + Some(Image { + id: self.id, + number: self.number, + placement: self.placement, + width: self.width, + height: self.height, + cols: self.cols, + rows: self.rows, + data, + format: self.format, + compressed: self.compressed, + }) + } + + #[cfg(not(unix))] + pub fn resolve(&self) -> Option { + None + } + + /// The `offset..offset+size` slice of `buf` (or `offset..` when `size == 0`), + /// copied out. `None` if `offset` itself falls outside the object; an `S=` + /// that runs past the end is *truncated* to what's there rather than + /// discarding the frame, which is what kitty does. + #[cfg(unix)] + fn slice_region(&self, buf: &[u8]) -> Option> { + let start = self.offset as usize; + if start > buf.len() { + return None; + } + let end = if self.size == 0 { + buf.len() + } else { + start.saturating_add(self.size as usize).min(buf.len()) + }; + buf.get(start..end).map(<[u8]>::to_vec) + } + + #[cfg(unix)] + fn read_file(&self) -> Option> { + use std::io::Read as _; + use std::os::unix::ffi::OsStringExt; + let path = std::path::PathBuf::from(std::ffi::OsString::from_vec(self.name.clone())); + + // Read through a bounded reader, not `fs::read`. The name is attacker + // -reachable (any program that can write to the pty picks it), and + // `fs::read` on `/dev/zero` never returns — on the daemon *reader* + // thread, which would wedge the pane's whole output path. Refusing + // anything that isn't a regular file also keeps us off fifos and + // devices, where the open itself can block. + let file = std::fs::File::open(&path).ok()?; + let meta = file.metadata().ok()?; + if !meta.is_file() || meta.len() as usize > MAX_IMAGE_BYTES { + return None; + } + let mut bytes = Vec::with_capacity(meta.len() as usize); + file.take(MAX_IMAGE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() > MAX_IMAGE_BYTES { + return None; + } + + let out = self.slice_region(&bytes)?; + // A temp file is the sender's one-shot handoff: remove it after reading + // so the browser's per-frame temp files don't pile up. A named `t=f` + // file is the sender's to manage; leave it. + // + // Only unlink inside a known temp directory. `name` is an arbitrary path + // out of an escape sequence — `cat`ing a hostile file is enough to send + // one — so an unqualified `remove_file` here would delete anything the + // user can, `~/.ssh/id_ed25519` included. The spec requires this check + // for exactly that reason. + if self.medium == Medium::TempFile && path_is_in_temp_dir(&path) { + let _ = std::fs::remove_file(&path); + } + Some(out) + } + + /// `shm_open` + `mmap` the object, copy out the requested region, then + /// `shm_unlink` it (the sender allocates a fresh object per frame and + /// expects the terminal to reclaim it — matching kitty/ghostty). + #[cfg(unix)] + fn read_shared(&self) -> Option> { + use std::os::raw::c_void; + // A POSIX shm name is a single `/`-prefixed component — no embedded + // separators, no `..`. Some platforms resolve `shm_open` against the + // filesystem, where a name like `/../../etc/passwd` would escape the shm + // namespace and reach a real path we'd then `shm_unlink`. + if !shm_name_is_wellformed(&self.name) { + return None; + } + // The name must be a C string; kitty shm names look like `/px-…`. + let cname = std::ffi::CString::new(self.name.clone()).ok()?; + // SAFETY: FFI. `shm_open` with O_RDONLY on a name the sender created; + // we only ever read, mmap read-only, and always munmap/close/unlink on + // every exit path below. + unsafe { + let fd = libc::shm_open(cname.as_ptr(), libc::O_RDONLY, 0); + if fd < 0 { + return None; + } + // Size the mapping from the object itself; the sender may not send + // `S=`, and mapping past the end would fault on access. + // Refuse an object bigger than a frame can carry rather than mapping + // and copying it out only for the send to fail downstream. + let mut st: libc::stat = std::mem::zeroed(); + if libc::fstat(fd, &mut st) != 0 + || st.st_size <= 0 + || st.st_size as u64 > MAX_IMAGE_BYTES as u64 + { + libc::close(fd); + libc::shm_unlink(cname.as_ptr()); + return None; + } + let len = st.st_size as usize; + let addr = libc::mmap( + std::ptr::null_mut(), + len, + libc::PROT_READ, + libc::MAP_SHARED, + fd, + 0, + ); + libc::close(fd); + if addr == libc::MAP_FAILED { + libc::shm_unlink(cname.as_ptr()); + return None; + } + let mapped = std::slice::from_raw_parts(addr as *const u8, len); + let out = self.slice_region(mapped); + libc::munmap(addr as *mut c_void, len); + // One-shot: the sender expects us to reclaim the object. + libc::shm_unlink(cname.as_ptr()); + out + } + } +} + +/// Whether `path` sits inside a directory the platform hands out for temp files, +/// which is the only place a `t=t` handoff may be unlinked. +/// +/// Compares *canonicalized* paths so `/tmp/../home/me/.ssh/id_ed25519` — which +/// has the right prefix textually — doesn't pass. On macOS `TMPDIR` is a +/// per-user path under `/var/folders/…` that canonicalizes through the +/// `/private` symlink, so `std::env::temp_dir()` is canonicalized too rather +/// than compared raw. +#[cfg(unix)] +fn path_is_in_temp_dir(path: &std::path::Path) -> bool { + let Ok(real) = path.canonicalize() else { + return false; + }; + // `/dev/shm` is where a `t=t` sender that wanted shm-like semantics without + // `shm_open` puts its handoff; kitty accepts it alongside the temp dirs. + let candidates = [ + std::env::temp_dir(), + std::path::PathBuf::from("/tmp"), + std::path::PathBuf::from("/dev/shm"), + ]; + candidates.iter().any(|dir| { + dir.canonicalize() + .is_ok_and(|d| real.starts_with(&d) && real != d) + }) +} + +/// Whether `name` is a well-formed POSIX shm object name: a leading `/` followed +/// by one non-empty component with no further separators and no `.`/`..`. +#[cfg(unix)] +fn shm_name_is_wellformed(name: &[u8]) -> bool { + // POSIX caps the name at NAME_MAX; 255 is the floor every platform we build + // for meets, and no real sender comes close. + if name.len() < 2 || name.len() > 255 || name[0] != b'/' { + return false; + } + let rest = &name[1..]; + !rest.contains(&b'/') && !rest.contains(&0) && rest != b"." && rest != b".." +} + +/// A delete request distilled from an `a=d` command, in the compact form the +/// daemon forwards to the client's image store. The full kitty delete grammar is +/// rich (by id, by placement, by cell, by z-index, …); the client only needs the +/// target selector plus the id/placement it may scope to, which is all any sender +/// tty7 targets — most often `d=A` (delete everything) — actually uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageDelete { + /// The `d=` selector byte (e.g. `A`/`a` = all, `i` = by id, `p` = by + /// placement). Uppercase variants also free the image data in kitty; the + /// client frees unconditionally, so case only affects which images match. + pub target: u8, + pub id: u32, + pub placement: u32, +} + +impl ImageDelete { + pub fn from_control(c: &Control) -> Self { + Self { + // A bare `a=d` with no `d=` means "delete all visible placements", + // which kitty spells `a` — normalize the unset case to it. + target: if c.delete == 0 { b'a' } else { c.delete }, + id: c.id, + placement: c.placement, + } + } + + /// A fixed 9-byte frame: the selector byte then id and placement (LE). + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(9); + out.push(self.target); + out.extend_from_slice(&self.id.to_le_bytes()); + out.extend_from_slice(&self.placement.to_le_bytes()); + out + } + + pub fn decode(bytes: &[u8]) -> Option { + if bytes.len() < 9 { + return None; + } + Some(Self { + target: bytes[0], + id: u32::from_le_bytes(bytes[1..5].try_into().unwrap()), + placement: u32::from_le_bytes(bytes[5..9].try_into().unwrap()), + }) + } +} + +/// What a fed command turned into. Query/Delete carry no pixels; the caller +/// writes the query reply back to the PTY and applies deletes to its store. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Event { + /// An `a=q` probe. `reply` is the bytes to write to the PTY; `honored` says + /// whether we accepted the requested medium (for logging/metrics only). + Query { reply: Vec, honored: bool }, + /// A complete image transmission (`a=T`/`a=t`). + Image(Image), + /// A transmission whose pixels live in a file or POSIX shm object rather + /// than inline in the escape. The parser can't read the filesystem (it must + /// stay pure and host-agnostic), so it hands the reference to the daemon + /// pane — which is co-located with the sender on a *local* pane — to `mmap` + /// / read and unlink. This is the zero-copy, zero-inflate path a sender like + /// `terminal-browser` prefers; see [`MediumTransfer`]. + ImageFromMedium(MediumTransfer), + /// An `a=d` delete request. + Delete(Control), +} + +/// A file/shm transmission the daemon must resolve into pixels. The `name` is +/// the (base64-decoded) payload the sender put after the `;`: a filesystem path +/// for [`Medium::File`]/[`Medium::TempFile`], or a POSIX shm object name for +/// [`Medium::Shared`]. `offset`/`size` bound the region to read (`size == 0` +/// means "to end of object"). All the metadata needed to build the final +/// [`Image`] rides along so the daemon does no parsing of its own. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediumTransfer { + pub medium: Medium, + pub name: Vec, + pub offset: u32, + pub size: u32, + pub id: u32, + pub number: u32, + pub placement: u32, + pub width: u32, + pub height: u32, + pub cols: u32, + pub rows: u32, + pub format: WireFormat, + /// Whether the referenced bytes are zlib-compressed (`o=z`). A sender that + /// picks shm/file for speed sends raw pixels, but honor the flag regardless. + pub compressed: bool, +} + +/// State for reassembling a chunked (`m=1`) direct transmission. Kitty +/// serializes chunked transmissions — only one is in flight at a time — so a +/// single pending accumulator suffices. +#[derive(Default)] +struct Pending { + control: Control, + /// Accumulated *base64* text across chunks (decoded once, at the end). + base64: Vec, +} + +/// Reassembles and decodes kitty graphics commands emitted by [`ApcTokenizer`]. +/// +/// Feed it each `_G` command payload; it returns zero or more [`Event`]s. It +/// owns the chunk-reassembly buffer and the base64/zlib decode, so the daemon +/// pane just forwards the resulting [`Image`] out-of-band and writes any +/// [`Event::Query`] reply to the PTY. +#[derive(Default)] +pub struct GraphicsParser { + pending: Option, + /// Whether the sender shares this host's filesystem (a local pane). Only + /// then can we honor file/shm transfer, whose names are host-local; a pane + /// running over SSH must stay on inline `t=d` so the pixels ride the tunnel. + local: bool, +} + +impl GraphicsParser { + pub fn new() -> Self { + Self::default() + } + + /// A parser that may honor file/shm transfer because the sender is on this + /// host (see [`GraphicsParser::local`]). + pub fn new_local(local: bool) -> Self { + Self { + local, + ..Self::default() + } + } + + /// Whether a `t=f`/`t=t`/`t=s` transfer can actually be resolved here: the + /// sender has to share this host's filesystem (a local pane), and + /// [`MediumTransfer::resolve`] has to have a real implementation on this + /// platform (it is unix-only). Both `query_reply` and `finalize` route + /// through this so what we advertise and what we accept can't drift apart. + fn honors_indirect_media(&self) -> bool { + self.local && cfg!(unix) + } + + /// Feed one complete `_G` command payload (the bytes [`ApcTokenizer`] + /// delivers). Returns an [`Event`] when a command completes (a query, a + /// finished image, or a delete); returns `None` for an intermediate chunk of + /// a still-incomplete transmission. + pub fn feed(&mut self, command: &[u8]) -> Option { + let control = Control::parse(command)?; + let data = payload_data(command); + + // A continuation chunk of a chunked transmission carries only `m=…` — no + // `a=` — so its action defaults to `Transmit`. Route anything while a + // transmission is pending straight to the chunk accumulator, which keeps + // the *first* chunk's real control keys. + if self.pending.is_some() { + return self.accept_chunk(control, data); + } + + match control.action { + Action::Query => self.query_reply(&control), + Action::Delete => Some(Event::Delete(control)), + Action::TransmitAndDisplay | Action::Transmit => self.accept_chunk(control, data), + // `a=p` places an image transmitted by an *earlier* command, which + // needs the stored-image table this parser deliberately doesn't + // keep. Routing it through `accept_chunk` would emit an `Image` with + // an empty payload — a frame the client can only throw away — so + // drop it here instead. Senders that split transmit from placement + // get nothing; `a=T` (the shape every sender tty7 targets uses) + // is unaffected. + Action::Display => None, + // An action we don't handle, with nothing in flight: drop it. + Action::Other => None, + } + } + + /// Append a (possibly first) chunk; finalize when `m` is not set. + fn accept_chunk(&mut self, control: Control, data: &[u8]) -> Option { + // The first chunk carries the real control keys; later chunks repeat + // only `m=`. Preserve the first chunk's control across the transmission. + let is_first = self.pending.is_none(); + if is_first { + self.pending = Some(Pending { + control, + base64: Vec::new(), + }); + } + let pending = self.pending.as_mut()?; + pending.base64.extend_from_slice(data); + if pending.base64.len() > MAX_TRANSMISSION_BASE64 { + log::debug!( + "kitty graphics: abandoning a transmission past {MAX_TRANSMISSION_BASE64} base64 bytes" + ); + self.pending = None; // abandon an oversized transmission + return None; + } + if control.more { + return None; // more chunks to come + } + // Complete: decode and emit. + let Pending { control, base64 } = self.pending.take()?; + self.finalize(control, &base64) + } + + fn finalize(&self, control: Control, base64: &[u8]) -> Option { + // Base64-decode the payload once. For direct transmission this *is* the + // (still-compressed) pixels; for file/shm it's the path/object name. + let data = BASE64.decode(base64).ok()?; + + // File/shm transfer: the payload names bytes on this host. Hand the + // reference to the daemon to resolve — but only on a local pane, where + // the name is meaningful and reading it can't leak across an SSH tunnel. + // A `query_reply` earlier already refused these mediums on a remote + // pane, so a well-behaved sender never reaches here; the guard is + // belt-and-suspenders. + if control.medium != Medium::Direct { + if !self.honors_indirect_media() { + return None; + } + return Some(Event::ImageFromMedium(MediumTransfer { + medium: control.medium, + name: data, + offset: control.offset, + size: control.size, + id: control.id, + number: control.number, + placement: control.placement, + width: control.width, + height: control.height, + cols: control.cols, + rows: control.rows, + format: control.format, + compressed: control.compressed, + })); + } + + // Direct: inflation is deferred to the client (`to_rgba8`) so the + // payload rides the socket — and any SSH tunnel — compressed. + Some(Event::Image(Image { + id: control.id, + number: control.number, + placement: control.placement, + width: control.width, + height: control.height, + cols: control.cols, + rows: control.rows, + data, + format: control.format, + compressed: control.compressed, + })) + } + + /// Build the `a=q` reply. On a local unix pane we honor direct, file, and + /// shm transfer, so any of those probes gets `OK`; otherwise only direct is + /// honored and a `t=f`/`t=s`/`t=t` probe gets an error — which makes a + /// sender like `terminal-browser` fall back to inline `t=d` on its own. + /// + /// The answer has to track what [`MediumTransfer::resolve`] can actually do, + /// including on the platform axis: it is unix-only, so replying `OK` to a + /// file/shm probe on Windows would talk a sender into a medium whose every + /// frame we then silently discard, leaving the pane blank. + fn query_reply(&self, control: &Control) -> Option { + let honored = control.medium == Medium::Direct || self.honors_indirect_media(); + // `q=` asks us to stay quiet: 1 suppresses success replies, 2 suppresses + // failures too. This matters because the reply is written back to the + // *PTY* — it arrives as if the user had typed it. A sender that asked + // for silence and gets `\x1b_Gi=1;OK\x1b\` anyway has those bytes land + // in its stdin, or, if it already exited, on the shell's input line. + if control.quiet >= 2 || (control.quiet == 1 && honored) { + return None; + } + let status: &[u8] = if honored { b"OK" } else { b"ENOTSUPPORTED" }; + // Echo back the id (preferred) or number the sender used, exactly as + // kitty does, so the sender can correlate the reply. + let mut reply = Vec::with_capacity(32); + reply.extend_from_slice(b"\x1b_G"); + if control.id != 0 { + reply.extend_from_slice(format!("i={}", control.id).as_bytes()); + } else if control.number != 0 { + reply.extend_from_slice(format!("I={}", control.number).as_bytes()); + } else { + reply.extend_from_slice(b"i=0"); + } + reply.push(b';'); + reply.extend_from_slice(status); + reply.extend_from_slice(b"\x1b\\"); + Some(Event::Query { reply, honored }) + } +} + +/// Expand tightly packed RGB into RGBA with an opaque alpha channel. +fn rgb_to_rgba(rgb: &[u8]) -> Vec { + let mut out = Vec::with_capacity(rgb.len() / 3 * 4); + for px in rgb.chunks_exact(3) { + out.extend_from_slice(px); + out.push(0xff); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn collect(chunks: &[&[u8]]) -> Vec> { + let mut tok = ApcTokenizer::new(); + let mut out = Vec::new(); + for c in chunks { + tok.feed(c, |_| {}, |cmd| out.push(cmd.to_vec())); + } + out + } + + /// Feed `chunks` and return the concatenated passthrough stream (input with + /// every `_G` sequence stripped) alongside the extracted commands. + fn split(chunks: &[&[u8]]) -> (Vec, Vec>) { + let mut tok = ApcTokenizer::new(); + let mut pass = Vec::new(); + let mut cmds = Vec::new(); + for c in chunks { + tok.feed( + c, + |b| pass.extend_from_slice(b), + |cmd| cmds.push(cmd.to_vec()), + ); + } + (pass, cmds) + } + + #[test] + fn extracts_a_graphics_command_between_esc_underscore_and_st() { + assert_eq!( + collect(&[b"\x1b_Ga=T,f=32;AAAA\x1b\\"]), + vec![b"Ga=T,f=32;AAAA".to_vec()] + ); + } + + #[test] + fn non_graphics_apc_is_ignored() { + // An APC that doesn't start with `G` (e.g. some other program's APC) is + // discarded, and a graphics command right after is still caught. + assert_eq!( + collect(&[b"\x1b_Xhello\x1b\\\x1b_Ga=q;AAAA\x1b\\"]), + vec![b"Ga=q;AAAA".to_vec()] + ); + } + + #[test] + fn command_split_across_reads_is_reassembled() { + assert_eq!( + collect(&[b"\x1b_Ga=T,", b"f=32;AA", b"AA\x1b", b"\\"]), + vec![b"Ga=T,f=32;AAAA".to_vec()] + ); + } + + #[test] + fn byte_at_a_time_delivery_crosses_every_state() { + let stream = b"plain\x1b_Ga=q;AAAA\x1b\\more"; + let chunks: Vec<&[u8]> = stream.chunks(1).collect(); + assert_eq!(collect(&chunks), vec![b"Ga=q;AAAA".to_vec()]); + } + + #[test] + fn passthrough_is_input_with_graphics_stripped() { + let (pass, cmds) = split(&[b"before\x1b_Ga=T;AAAA\x1b\\after"]); + assert_eq!(pass, b"beforeafter".to_vec()); + assert_eq!(cmds, vec![b"Ga=T;AAAA".to_vec()]); + } + + #[test] + fn non_graphics_apc_passes_through_verbatim() { + // A foreign APC (e.g. tmux) must reach the client's VT parser unchanged, + // while a graphics command in the same stream is still stripped. + let (pass, cmds) = split(&[b"\x1b_Xhello\x1b\\\x1b_Ga=q;AAAA\x1b\\end"]); + assert_eq!(pass, b"\x1b_Xhello\x1b\\end".to_vec()); + assert_eq!(cmds, vec![b"Ga=q;AAAA".to_vec()]); + } + + #[test] + fn lone_esc_and_other_escapes_are_preserved_in_passthrough() { + // A CSI sequence and a bare ESC must survive; only `_G` is removed. + let (pass, cmds) = split(&[b"a\x1b[31mred\x1b_Ga=d\x1b\\z"]); + assert_eq!(pass, b"a\x1b[31mredz".to_vec()); + assert_eq!(cmds, vec![b"Ga=d".to_vec()]); + } + + #[test] + fn passthrough_survives_being_split_mid_sequence() { + // Feed the same stream one byte at a time; the passthrough must still be + // exactly the input minus the graphics command. + let stream = b"x\x1b_Ga=T;QUJD\x1b\\y\x1b_Zother\x1b\\z"; + let chunks: Vec<&[u8]> = stream.chunks(1).collect(); + let (pass, cmds) = split(&chunks); + assert_eq!(pass, b"xy\x1b_Zother\x1b\\z".to_vec()); + assert_eq!(cmds, vec![b"Ga=T;QUJD".to_vec()]); + } + + #[test] + fn sniffer_ties_tokenizer_and_parser_together() { + let mut s = GraphicsSniffer::new(); + let mut pass = Vec::new(); + let events = s.feed(b"hi\x1b_Gi=1,a=q,t=d;AAAA\x1b\\bye", |b| { + pass.extend_from_slice(b) + }); + assert_eq!(pass, b"hibye".to_vec()); + assert_eq!(events.len(), 1); + match &events[0] { + Event::Query { reply, honored } => { + assert!(honored); + assert_eq!(reply, b"\x1b_Gi=1;OK\x1b\\"); + } + _ => panic!("expected query"), + } + } + + #[test] + fn sniff_takes_zero_copy_fast_path_without_graphics() { + let mut s = GraphicsSniffer::new(); + // A chunk full of CSI escapes but no APC borrows straight through. + match s.sniff(b"\x1b[31mred\x1b[0m plain") { + Sniffed::Plain(b) => assert_eq!(b, b"\x1b[31mred\x1b[0m plain"), + Sniffed::Segments(_) => panic!("expected the borrowed fast path"), + } + } + + #[test] + fn sniff_preserves_stream_order_of_text_and_images() { + let pixel = [0xffu8, 0x00, 0x00, 0xff]; + let b64 = BASE64.encode(pixel); + let stream = format!("A\x1b_Ga=T,f=32,t=d,s=1,v=1,i=1;{b64}\x1b\\B"); + let mut s = GraphicsSniffer::new(); + let segs = match s.sniff(stream.as_bytes()) { + Sniffed::Segments(segs) => segs, + Sniffed::Plain(_) => panic!("graphics present"), + }; + // Text "A", then the image, then text "B" — in that order. + assert_eq!(segs.len(), 3); + assert_eq!(segs[0], Segment::Output(b"A".to_vec())); + assert!(matches!(&segs[1], Segment::Image(img) if img.id == 1)); + assert_eq!(segs[2], Segment::Output(b"B".to_vec())); + } + + #[test] + fn sniff_emits_query_and_delete_segments() { + let mut s = GraphicsSniffer::new(); + let segs = match s.sniff(b"\x1b_Gi=7,a=q,t=d;AAAA\x1b\\x\x1b_Ga=d,d=A\x1b\\") { + Sniffed::Segments(segs) => segs, + Sniffed::Plain(_) => panic!("graphics present"), + }; + assert_eq!(segs[0], Segment::Query(b"\x1b_Gi=7;OK\x1b\\".to_vec())); + assert_eq!(segs[1], Segment::Output(b"x".to_vec())); + assert!(matches!(&segs[2], Segment::Delete(d) if d.target == b'A')); + } + + #[test] + fn sniff_coalesces_adjacent_passthrough_runs() { + // A foreign APC between two text runs is passthrough, so the whole thing + // collapses to a single output segment (no graphics segment at all). + let mut s = GraphicsSniffer::new(); + match s.sniff(b"a\x1b_Zother\x1b\\b") { + Sniffed::Segments(segs) => { + assert_eq!(segs, vec![Segment::Output(b"a\x1b_Zother\x1b\\b".to_vec())]); + } + Sniffed::Plain(_) => panic!("an APC opener forces the slow path"), + } + } + + #[test] + fn resyncs_on_new_apc_after_an_unterminated_one() { + assert_eq!( + collect(&[b"\x1b_Ga=T;dropped\x1b_Ga=q;AAAA\x1b\\"]), + vec![b"Ga=q;AAAA".to_vec()] + ); + } + + #[test] + fn oversized_chunk_is_abandoned_and_stream_recovers() { + let mut big = b"\x1b_G".to_vec(); + big.extend(std::iter::repeat_n(b'x', MAX_APC_PAYLOAD + 1)); + big.extend_from_slice(b"\x1b\\\x1b_Ga=q;AAAA\x1b\\"); + assert_eq!(collect(&[&big]), vec![b"Ga=q;AAAA".to_vec()]); + } + + #[test] + fn control_parses_the_keys_terminal_browser_sends() { + let c = + Control::parse(b"Ga=T,f=32,o=z,s=1920,v=1080,t=d,i=42,p=1,C=1,q=2,m=1;xxxx").unwrap(); + assert_eq!(c.action, Action::TransmitAndDisplay); + assert_eq!(c.format, WireFormat::Rgba); + assert!(c.compressed); + assert_eq!(c.width, 1920); + assert_eq!(c.height, 1080); + assert_eq!(c.medium, Medium::Direct); + assert_eq!(c.id, 42); + assert!(c.more); + assert_eq!(c.quiet, 2); + } + + #[test] + fn query_reply_refuses_shm_file_on_remote_pane() { + // The default parser is non-local (the SSH-safe posture): direct is + // honored, file/shm are refused so the sender falls back to inline. + let mut p = GraphicsParser::new(); + // The exact probe terminal-browser's graphics.ts sends. + let ev = p.feed(b"Gi=4207,a=q,t=d,f=24,s=1,v=1;AAAA").unwrap(); + match ev { + Event::Query { reply, honored } => { + assert!(honored); + assert_eq!(reply, b"\x1b_Gi=4207;OK\x1b\\".to_vec()); + } + _ => panic!("expected query"), + } + // The shm/file medium probes must be refused so the sender falls back. + let ev = p.feed(b"Gi=299,a=q,t=s,f=32,s=1,v=1;L3B4LXE").unwrap(); + match ev { + Event::Query { reply, honored } => { + assert!(!honored); + assert_eq!(reply, b"\x1b_Gi=299;ENOTSUPPORTED\x1b\\".to_vec()); + } + _ => panic!("expected query"), + } + } + + #[test] + #[cfg(unix)] + fn query_reply_ok_for_shm_file_on_local_pane() { + // A local pane shares the sender's filesystem, so file/shm are honored: + // this is what unlocks terminal-browser's zero-inflate fast path. + let mut p = GraphicsParser::new_local(true); + for probe in [ + &b"Gi=299,a=q,t=s,f=32,s=1,v=1;L3B4LXE"[..], + &b"Gi=300,a=q,t=f,f=32,s=1,v=1;L3RtcC94"[..], + &b"Gi=301,a=q,t=t,f=32,s=1,v=1;L3RtcC94"[..], + ] { + match p.feed(probe).unwrap() { + Event::Query { honored, reply } => { + assert!(honored, "local pane should honor {probe:?}"); + assert!(reply.ends_with(b";OK\x1b\\"), "reply was {reply:?}"); + } + _ => panic!("expected query"), + } + } + } + + #[test] + #[cfg(unix)] + fn shared_transmission_surfaces_medium_transfer_on_local() { + // `terminal-browser`'s shm transmit template: raw f=32, no o=z, the + // shm object name base64'd after the `;`. + let name = b"/px-abc123"; + let b64 = BASE64.encode(name); + let cmd = format!("Ga=T,f=32,t=s,s=64,v=1,i=7,S=256;{b64}"); + let mut p = GraphicsParser::new_local(true); + match p.feed(cmd.as_bytes()).unwrap() { + Event::ImageFromMedium(t) => { + assert_eq!(t.medium, Medium::Shared); + assert_eq!(t.name, name); + assert_eq!(t.id, 7); + assert_eq!((t.width, t.height), (64, 1)); + assert_eq!(t.size, 256); + assert!(!t.compressed); + } + _ => panic!("expected medium transfer"), + } + } + + #[test] + fn shared_transmission_dropped_on_remote() { + // A non-local parser must never surface a file/shm transfer even if a + // misbehaving sender ignored the refusal and sent one anyway. + let b64 = BASE64.encode(b"/px-abc123"); + let cmd = format!("Ga=T,f=32,t=s,s=64,v=1,i=7;{b64}"); + let mut p = GraphicsParser::new(); + assert_eq!(p.feed(cmd.as_bytes()), None); + } + + #[test] + #[cfg(unix)] + fn file_transfer_resolves_and_temp_file_is_removed() { + // A temp-file transfer: write raw RGBA to a temp path, resolve it, and + // confirm the file is deleted afterward (the one-shot handoff contract). + let rgba = [0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88]; + let dir = std::env::temp_dir(); + let path = dir.join(format!("tty7-kitty-test-{}.rgba", std::process::id())); + std::fs::write(&path, rgba).unwrap(); + let t = MediumTransfer { + medium: Medium::TempFile, + name: path.clone().into_os_string().into_encoded_bytes(), + offset: 0, + size: 0, + id: 1, + number: 0, + placement: 0, + width: 2, + height: 1, + cols: 0, + rows: 0, + format: WireFormat::Rgba, + compressed: false, + }; + let img = t.resolve().expect("resolve temp file"); + assert_eq!(img.data, rgba); + assert_eq!(img.to_rgba8().unwrap(), rgba); + assert!(!path.exists(), "temp file should be removed after read"); + } + + /// Build a `t=t` transfer naming `path`, the shape a hostile escape uses. + #[cfg(unix)] + fn temp_file_transfer(path: &std::path::Path) -> MediumTransfer { + MediumTransfer { + medium: Medium::TempFile, + name: path.to_path_buf().into_os_string().into_encoded_bytes(), + offset: 0, + size: 0, + id: 1, + number: 0, + placement: 0, + width: 2, + height: 1, + cols: 0, + rows: 0, + format: WireFormat::Rgba, + compressed: false, + } + } + + #[test] + #[cfg(unix)] + fn a_temp_file_transfer_outside_the_temp_dir_is_read_but_not_deleted() { + // `t=t` names an arbitrary path out of an escape sequence — `cat`ing a + // hostile file is enough to send one. Unlinking whatever it points at + // would delete e.g. `~/.ssh/id_ed25519`. Read it, leave it. + // + // `/etc/hosts` stands in for the victim: a real, readable file that is + // never under a temp dir on any host we build for. Deliberately *not* + // something derived from `CARGO_MANIFEST_DIR` — a checkout can itself + // live under `/tmp`, which would make the test assert the opposite of + // what it means to. + let victim = std::path::Path::new("/etc/hosts"); + let img = temp_file_transfer(victim).resolve().expect("still reads"); + assert!(!img.data.is_empty()); + assert!( + victim.exists(), + "a path outside the temp dir must survive a t=t handoff" + ); + } + + #[test] + #[cfg(unix)] + fn the_temp_dir_check_resolves_symlinks_and_dotdot_before_comparing() { + // The prefix has to be checked on the *canonicalized* path: `/tmp/../etc` + // starts with `/tmp` textually but lands nowhere near it. macOS also + // routes both `/tmp` and `TMPDIR` through `/private`, so a raw string + // compare would reject the legitimate case too. + let dir = tempfile::tempdir().unwrap(); + let inside = dir.path().join("frame.rgba"); + std::fs::write(&inside, [0u8; 4]).unwrap(); + assert!(path_is_in_temp_dir(&inside)); + + assert!(!path_is_in_temp_dir(std::path::Path::new( + "/tmp/../etc/hosts" + ))); + assert!(!path_is_in_temp_dir(std::path::Path::new("/etc/hosts"))); + // A path that doesn't resolve at all can't be vouched for. + assert!(!path_is_in_temp_dir(std::path::Path::new( + "/nonexistent-tty7-test-path" + ))); + // The temp dir itself is not a file we'd ever unlink. + assert!(!path_is_in_temp_dir(&std::env::temp_dir())); + } + + #[test] + #[cfg(unix)] + fn a_transfer_naming_a_character_device_is_refused() { + // `fs::read` on `/dev/zero` never returns, and this runs on the daemon's + // reader thread — the pane's whole output path would wedge. + let t = temp_file_transfer(std::path::Path::new("/dev/zero")); + assert_eq!(t.resolve(), None); + } + + #[test] + #[cfg(unix)] + fn a_malformed_shm_name_is_refused_before_shm_open() { + for name in [ + &b"/../../etc/passwd"[..], + &b"/sub/dir"[..], + &b"no-leading-slash"[..], + &b"/"[..], + &b"/.."[..], + ] { + assert!( + !shm_name_is_wellformed(name), + "{:?} should be refused", + String::from_utf8_lossy(name) + ); + } + assert!(shm_name_is_wellformed(b"/px-abc123")); + } + + #[test] + fn a_bomb_that_declares_huge_dimensions_is_still_bounded() { + // The declared size is the sender's to choose, so bounding the inflate + // by it alone isn't enough: `s=65535,v=65535` works out to a 17 GB + // budget, which hands a bomb back exactly the allocation the bound was + // meant to deny. The absolute `MAX_IMAGE_BYTES` clamp is what closes it. + // (`to_rgba8_bounds_the_inflate_by_declared_dimensions` covers the + // ordinary case, where the declared size is the tighter of the two.) + let img = Image { + id: 1, + number: 0, + placement: 0, + width: 65535, + height: 65535, + cols: 0, + rows: 0, + // Has to inflate past `MAX_IMAGE_BYTES` to exercise the clamp at + // all — anything smaller decodes on its merits and proves nothing. + // Zeros deflate to a few KB, so the payload on the wire stays tiny, + // which is the whole point of the attack. + data: miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; MAX_IMAGE_BYTES + 1], 6), + format: WireFormat::Rgba, + compressed: true, + }; + assert!( + img.decoded_len().unwrap() > MAX_IMAGE_BYTES, + "the declared budget must be the looser bound for this to test anything" + ); + assert!( + img.data.len() < 1 << 20, + "the compressed payload must stay small: {}", + img.data.len() + ); + assert_eq!(img.to_rgba8(), None, "the clamp has to reject it"); + } + + #[test] + fn a_transmission_cap_leaves_room_for_the_wire_frame() { + // `MAX_TRANSMISSION_BASE64` has to decode to something that still fits + // in a `MAX_FRAME` wire frame with the header on it — otherwise + // `write_frame` fails, and the daemon's writer treats that as fatal and + // drops the client's whole connection over one image. + let decoded_max = MAX_TRANSMISSION_BASE64 / 4 * 3; + assert!( + decoded_max + HEADER_LEN <= crate::daemon::protocol::MAX_FRAME, + "{decoded_max} + {HEADER_LEN} must fit in {}", + crate::daemon::protocol::MAX_FRAME + ); + assert!(MAX_IMAGE_BYTES + HEADER_LEN <= crate::daemon::protocol::MAX_FRAME); + } + + #[test] + fn a_quiet_sender_gets_no_reply_written_back_to_its_pty() { + // The reply is written to the *PTY* — it arrives as if typed. `q=1` + // suppresses success, `q=2` suppresses failures too. + let mut p = GraphicsParser::new_local(true); + assert_eq!(p.feed(b"Gi=1,a=q,t=d,q=1;AAAA"), None); + assert_eq!(p.feed(b"Gi=2,a=q,t=d,q=2;AAAA"), None); + // A refusal still reaches a `q=1` sender, which needs to hear it to fall + // back, and an unquiet probe is answered as before. + let mut remote = GraphicsParser::new(); + assert!(matches!( + remote.feed(b"Gi=3,a=q,t=s,q=1;AAAA"), + Some(Event::Query { honored: false, .. }) + )); + assert!(matches!( + p.feed(b"Gi=4,a=q,t=d;AAAA"), + Some(Event::Query { honored: true, .. }) + )); + } + + #[test] + fn a_quiet_key_past_a_byte_does_not_wrap_to_chatty() { + assert_eq!(Control::parse(b"Ga=q,q=256;").unwrap().quiet, 255); + } + + #[test] + fn graphics_after_an_unterminated_foreign_apc_are_still_lifted() { + // A foreign APC that never sends its ST used to swallow every `ESC _G` + // after it: the graphics were forwarded as APC text, which the client's + // vte then discards, so the image vanished with no trace. + let input = b"\x1b_somevendor\x1b_Ga=q;AAAA\x1b\\"; + assert_eq!(collect(&[&input[..]]), vec![b"Ga=q;AAAA".to_vec()]); + } + + #[test] + fn an_escape_interrupting_a_graphics_command_still_reaches_the_client() { + // The abandoned command's bytes stay stripped, but the escape that + // interrupted it belongs to the terminal — swallowing it turned a + // following `\x1b[31m` into literal `31m` on screen. + let mut t = ApcTokenizer::new(); + let mut out = Vec::new(); + t.feed(b"\x1b_Gxx\x1b[31mred", |b| out.extend_from_slice(b), |_| {}); + assert_eq!(out, b"\x1b[31mred"); + } + + #[test] + fn a_place_only_command_emits_nothing_rather_than_an_empty_image() { + // `a=p` places an image transmitted earlier, which needs a stored-image + // table this parser doesn't keep. It used to fall through the chunk + // accumulator and emit an `Image` with no payload. + let mut p = GraphicsParser::new_local(true); + assert_eq!(p.feed(b"Ga=p,i=42,p=1;"), None); + } + + #[test] + #[cfg(unix)] + fn an_oversized_size_key_truncates_instead_of_dropping_the_frame() { + // kitty truncates an `S=` that runs past the object; discarding the + // whole frame loses an image a real sender would have seen rendered. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("frame.rgba"); + std::fs::write(&path, [1u8, 2, 3, 4]).unwrap(); + let mut t = temp_file_transfer(&path); + t.medium = Medium::File; // leave the file in place + t.size = 4096; + assert_eq!(t.resolve().expect("truncated, not dropped").data.len(), 4); + } + + #[test] + fn single_shot_rgba_transmission_decodes() { + // One 1x1 opaque-red RGBA pixel, uncompressed, direct. + let pixel = [0xffu8, 0x00, 0x00, 0xff]; + let b64 = BASE64.encode(pixel); + let cmd = format!("Ga=T,f=32,t=d,s=1,v=1,i=7;{b64}"); + let mut p = GraphicsParser::new(); + let ev = p.feed(cmd.as_bytes()).unwrap(); + match ev { + Event::Image(img) => { + assert_eq!(img.id, 7); + assert_eq!((img.width, img.height), (1, 1)); + assert!(!img.compressed); + assert_eq!(img.to_rgba8().unwrap(), pixel); + assert_eq!(img.format, WireFormat::Rgba); + } + _ => panic!("expected image"), + } + } + + #[test] + fn chunked_compressed_transmission_reassembles_and_inflates() { + // 2x1 RGBA (red, green), zlib-compressed, split into two direct chunks + // exactly the way terminal-browser frames a `t=d,o=z` transmission. + let rgba = [0xffu8, 0, 0, 0xff, 0x00, 0xff, 0x00, 0xff]; + let z = miniz_oxide::deflate::compress_to_vec_zlib(&rgba, 1); + let b64 = BASE64.encode(&z); + let mid = b64.len() / 2; + let first = format!("Ga=T,f=32,o=z,t=d,s=2,v=1,i=9,m=1;{}", &b64[..mid]); + let second = format!("Gm=0;{}", &b64[mid..]); + + let mut p = GraphicsParser::new(); + assert_eq!(p.feed(first.as_bytes()), None); // more chunks pending + let ev = p.feed(second.as_bytes()).unwrap(); + match ev { + Event::Image(img) => { + assert_eq!(img.id, 9); + assert_eq!((img.width, img.height), (2, 1)); + assert!(img.compressed); + // Wire payload stays compressed; the client inflates. + assert_eq!(img.data, z); + assert_eq!(img.to_rgba8().unwrap(), rgba); + } + _ => panic!("expected image"), + } + } + + #[test] + fn rgb_transmission_is_expanded_to_opaque_rgba() { + let rgb = [0x10u8, 0x20, 0x30]; // one pixel + let b64 = BASE64.encode(rgb); + let cmd = format!("Ga=T,f=24,t=d,s=1,v=1;{b64}"); + let mut p = GraphicsParser::new(); + match p.feed(cmd.as_bytes()).unwrap() { + Event::Image(img) => { + assert_eq!(img.to_rgba8().unwrap(), [0x10, 0x20, 0x30, 0xff]) + } + _ => panic!("expected image"), + } + } + + #[test] + fn delete_is_surfaced() { + let mut p = GraphicsParser::new(); + match p.feed(b"Ga=d,d=A").unwrap() { + Event::Delete(c) => { + assert_eq!(c.action, Action::Delete); + assert_eq!(c.delete, b'A'); + } + _ => panic!("expected delete"), + } + } + + #[test] + fn image_delete_normalizes_and_roundtrips() { + // A bare `a=d` (no selector) means "all visible placements" (`a`). + let bare = ImageDelete::from_control(&Control::parse(b"Ga=d").unwrap()); + assert_eq!(bare.target, b'a'); + // A scoped delete keeps its selector and id. + let scoped = ImageDelete::from_control(&Control::parse(b"Ga=d,d=i,i=5").unwrap()); + assert_eq!((scoped.target, scoped.id), (b'i', 5)); + assert_eq!(ImageDelete::decode(&scoped.encode()), Some(scoped)); + assert_eq!(ImageDelete::decode(&[b'a', 0, 0]), None); // too short + } + + #[test] + fn image_frame_roundtrips_without_touching_the_payload() { + let img = Image { + id: 42, + number: 3, + placement: 1, + width: 1920, + height: 1080, + cols: 80, + rows: 24, + data: vec![1, 2, 3, 4, 5, 6, 7], + format: WireFormat::Rgba, + compressed: true, + }; + let frame = img.encode_frame(); + // Header + payload, payload byte-identical (no base64 inflation). + assert_eq!(&frame[HEADER_LEN..], &img.data[..]); + assert_eq!(Image::decode_frame(&frame), Some(img)); + // A truncated frame is rejected, not panicked on. + assert_eq!(Image::decode_frame(&frame[..HEADER_LEN - 1]), None); + } + + #[test] + fn to_rgba8_bounds_the_inflate_by_declared_dimensions() { + // A hostile payload: a tiny compressed blob that inflates far past the + // 1x1 image it claims to be. Without a cap this would balloon into a + // multi-MB (in the wild, multi-GB) allocation; bounded by the declared + // `width * height * 4` it must be rejected instead. + let bomb = vec![0u8; 4 * 1024 * 1024]; // 4 MiB of zeros → tiny deflate + let z = miniz_oxide::deflate::compress_to_vec_zlib(&bomb, 9); + assert!(z.len() < bomb.len(), "payload must actually compress"); + let img = Image { + id: 1, + number: 0, + placement: 0, + width: 1, + height: 1, + cols: 0, + rows: 0, + data: z, + format: WireFormat::Rgba, + compressed: true, + }; + assert_eq!(img.to_rgba8(), None, "an over-budget inflate is dropped"); + + // A payload that inflates to exactly its declared size still decodes. + let pixels = vec![0xabu8; 4]; // 1x1 RGBA + let z = miniz_oxide::deflate::compress_to_vec_zlib(&pixels, 9); + let ok = Image { data: z, ..img }; + assert_eq!(ok.to_rgba8().as_deref(), Some(&pixels[..])); + } +} diff --git a/crates/tty7-core/src/core/mod.rs b/crates/tty7-core/src/core/mod.rs index 1f0d2dee..a72ca565 100644 --- a/crates/tty7-core/src/core/mod.rs +++ b/crates/tty7-core/src/core/mod.rs @@ -6,6 +6,7 @@ pub mod git; pub mod gitignore; #[allow(dead_code)] pub mod keychain; +pub mod kitty_graphics; pub mod logfile; pub mod machine; pub mod osc; diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 83b42485..5a6a59eb 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -9,9 +9,11 @@ use std::time::Duration; use portable_pty::{Child, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use crate::core::kitty_graphics::{GraphicsSniffer, Segment, Sniffed}; use crate::core::osc::OscTokenizer; use crate::daemon::protocol::{ - AuthResponse, DaemonMsg, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, ShellSpec, WinSize, + AuthResponse, DaemonMsg, MAX_FRAME, NativeSshSpec, PaneInfo, RemoteContext, RemoteKind, + ShellSpec, WinSize, }; use crate::daemon::shell_integration; @@ -466,24 +468,64 @@ fn notify(st: &mut PaneState, msg: DaemonMsg) { }); } -fn fan_out_output(st: &mut PaneState, bytes: &[u8], gate: &OutputGate) { +/// One gated message to the controller and to every observer. +/// +/// A send error just means that client is gone; ignore it and let the next +/// attach install a new sender. Successful sends are counted against the +/// pane gate, and the connection's writer thread credits them back. Observers +/// each carry their own gate and their own budget: one that has stopped +/// draining is dropped rather than allowed to hold the pane's output forever. +fn fan_out_one(st: &mut PaneState, msg: DaemonMsg, len: usize, gate: &OutputGate) { if let Some(sub) = &st.subscriber { - if sub.send(DaemonMsg::Output(bytes.to_vec())).is_ok() { - gate.add(bytes.len()); + if sub.send(msg.clone()).is_ok() { + gate.add(len); } } st.observers.retain(|obs| { - if obs.gate.queued_bytes() + bytes.len() as i64 > OBSERVER_BUDGET { + if obs.gate.queued_bytes() + len as i64 > OBSERVER_BUDGET { return false; } - if obs.tx.send(DaemonMsg::Output(bytes.to_vec())).is_err() { + if obs.tx.send(msg.clone()).is_err() { return false; } - obs.gate.add(bytes.len()); + obs.gate.add(len); true }); } +/// Fan one PTY read out to the controller and every observer. +/// +/// On the no-graphics fast path `frames` is empty and the whole passthrough +/// goes as a single `Output`. When a chunk carried graphics the frames are +/// forwarded in stream order instead, so an image lands at the same cursor cell +/// the sender drew it at. Each `Output` is gated on its own length; an image +/// frame is gated too (it rode the same PTY read); a delete is tiny and +/// ungated, matching the drain accounting in `server.rs`. +fn fan_out_output(st: &mut PaneState, bytes: &[u8], frames: Vec, gate: &OutputGate) { + if frames.is_empty() { + if !bytes.is_empty() { + fan_out_one(st, DaemonMsg::Output(bytes.to_vec()), bytes.len(), gate); + } + return; + } + for frame in frames { + match frame { + GraphicsFrame::Output(b) => { + if !b.is_empty() { + let len = b.len(); + fan_out_one(st, DaemonMsg::Output(b), len, gate); + } + } + GraphicsFrame::Image(frame) => { + let len = frame.len(); + fan_out_one(st, DaemonMsg::Image(frame), len, gate); + } + // Ungated, and `notify` already holds observers to their budget. + GraphicsFrame::Delete(sel) => notify(st, DaemonMsg::DeleteImage(sel)), + } + } +} + enum PaneBackend { Pty(PtyBackend), NativeSsh(NativeSshBackend), @@ -512,7 +554,13 @@ pub struct DaemonPane { pub id: u64, owner: Option, backend: PaneBackend, - writer: Mutex>, + /// The input side (keyboard input / pasted text): the PTY writer, or the + /// native-SSH channel writer. Behind a `Mutex` because writes can arrive from + /// different connection threads. `Arc`-shared so the reader thread can write + /// kitty-graphics query replies (`\x1b_G…;OK\x1b\\`) back to the PTY inline + /// with the sniff, without routing through `write_input`. + writer: Arc>>, + /// Set during teardown so the reader doesn't emit a spurious exit. shutting_down: Arc, gate: Arc, state: Arc>, @@ -571,6 +619,32 @@ impl DeathReporter { } } +/// One out-of-band frame the reader forwards to the subscriber, kept in stream +/// order so a kitty image lands at the cursor cell the sender drew it at: a chunk +/// carrying graphics splits into `Output` runs interleaved with `Image`/`Delete` +/// frames, and they must reach the client in that same order. `Output` becomes a +/// `DaemonMsg::Output`, `Image` an encoded image frame, `Delete` a compact +/// selector — see the reader loop's `sniff` handling. +enum GraphicsFrame { + Output(Vec), + Image(Vec), + Delete(Vec), +} + +/// Queue an encoded image frame for the subscriber, dropping any frame larger +/// than [`MAX_FRAME`]. The writer's `write_frame` rejects an oversize payload +/// with an error the writer loop treats as *fatal* — it would tear the client +/// off an otherwise-healthy pane. A single image that won't fit is not worth +/// that: drop it here so the rest of the stream keeps flowing. The inline +/// `t=d` path is bounded by `MAX_TRANSMISSION_BASE64` but that still admits +/// ~72 MiB of raw pixels, and the local shm/file path has no cap of its own, +/// so both can land here over the limit. +fn push_image_frame(frames: &mut Vec, frame: Vec) { + if frame.len() <= MAX_FRAME { + frames.push(GraphicsFrame::Image(frame)); + } +} + impl DaemonPane { pub fn spawn( id: u64, @@ -593,7 +667,7 @@ impl DaemonPane { drop(pair.slave); let reader_handle = pair.master.try_clone_reader()?; - let writer = pair.master.take_writer()?; + let writer = Arc::new(Mutex::new(pair.master.take_writer()?)); let state = Arc::new(Mutex::new(PaneState { id, @@ -625,7 +699,7 @@ impl DaemonPane { shell_pid, integration_dir: spawn.integration_dir, }), - writer: Mutex::new(writer), + writer: writer.clone(), shutting_down: shutting_down.clone(), gate: gate.clone(), state: state.clone(), @@ -677,6 +751,7 @@ impl DaemonPane { shutting_down, gate, reader_handle, + writer.clone(), move || foreground_command_running(&fg_master, shell_pid), ForegroundProbes { remote: Box::new(move || foreground_remote_context(&remote_master)), @@ -698,7 +773,10 @@ impl DaemonPane { ) -> anyhow::Result> { let bridge = crate::daemon::ssh::session::make_bridge(); let reader_handle: Box = Box::new(bridge.reader); - let writer: Box = Box::new(bridge.writer); + let writer: Arc>> = + Arc::new(Mutex::new(Box::new(bridge.writer))); + // The connect task fills this once authenticated; the pane exposes it to + // WS4/WS5 via `ssh_connection()`. let connection: crate::daemon::ssh::SharedConnection = Arc::new(Mutex::new(Weak::new())); let target = spec @@ -747,7 +825,7 @@ impl DaemonPane { handle: bridge.handle, connection: connection.clone(), }), - writer: Mutex::new(writer), + writer: writer.clone(), shutting_down: shutting_down.clone(), gate: gate.clone(), state: state.clone(), @@ -762,6 +840,7 @@ impl DaemonPane { shutting_down, gate, reader_handle, + writer.clone(), || false, ForegroundProbes { remote: Box::new(|| None), @@ -804,6 +883,10 @@ impl DaemonPane { shutting_down: Arc, gate: Arc, mut reader: Box, + // The pane's input side, shared so the reader can write kitty-graphics + // query replies (`\x1b_G…;OK\x1b\\`) straight back to the PTY — see the + // graphics sniff in the loop below. + writer: Arc>>, foreground_running: impl Fn() -> bool + Send + 'static, probes: ForegroundProbes, death: Arc, @@ -818,6 +901,20 @@ impl DaemonPane { .spawn(move || { crate::core::threads::promote_to_user_interactive(); let mut sniffer = OscSniffer::new(); + // Kitty graphics interception (issue #213): lifts image + // sequences out of the stream *before* the ring/subscriber see + // them, so the base64 pixels never enter replay and the client's + // VT parser never chews through them. Zero-copy on the common + // no-graphics chunk — see [`GraphicsSniffer::sniff`]. + // + // File/shm transfer (`t=s`/`t=f`/`t=t`) is honored only while the + // pane is *local*: the object/path names resolve on this host, and + // reading them can't leak across an SSH tunnel. A pane that starts + // local can `ssh` out mid-session, so the flag is refreshed from + // the same remote-context poll below. Seed it from the pane's + // current context so the first probe answers correctly. + let starts_local = state.lock().unwrap().remote.is_none(); + let mut graphics = GraphicsSniffer::new_local(starts_local); let mut buf = [0u8; 65536]; let trace = std::env::var("TTY7_TRACE").is_ok_and(|v| !v.is_empty() && v != "0"); @@ -854,7 +951,71 @@ impl DaemonPane { tr_reads += 1; tr_bytes += n as u64; } - let bytes = &buf[..n]; + let raw = &buf[..n]; + // Kitty graphics: strip any image sequences out of the + // stream before anything else sees them. On the common + // no-graphics chunk this borrows `raw` unchanged; only a + // chunk that actually carries `\x1b_G…` allocates. Query + // replies are written straight back to the PTY here, and + // any images/deletes are forwarded to the subscriber + // out-of-band below — none of it enters the replay ring. + // + // `frames` is the ordered list of out-of-band frames to + // forward *in stream position*: a kitty image anchors to + // the cursor cell as it stood when its command appeared, + // so the client must apply the text before an image, then + // the image, then the text after it, in that order. On + // the no-graphics fast path `frames` stays empty and the + // whole chunk is sent as one `Output`; only a chunk with + // graphics splits into interleaved frames. + let mut frames: Vec = Vec::new(); + let passthrough: std::borrow::Cow<[u8]> = match graphics.sniff(raw) { + Sniffed::Plain(b) => std::borrow::Cow::Borrowed(b), + Sniffed::Segments(segs) => { + let mut pass = Vec::new(); + for seg in segs { + match seg { + Segment::Output(b) => { + pass.extend_from_slice(&b); + frames.push(GraphicsFrame::Output(b)); + } + Segment::Query(reply) => { + if let Ok(mut w) = writer.lock() { + let _ = w.write_all(&reply); + let _ = w.flush(); + } + } + Segment::Image(img) => { + push_image_frame(&mut frames, img.encode_frame()); + } + Segment::ImageFromMedium(transfer) => { + // File/shm handoff on a local pane: + // read (and unlink) the object here, + // then forward the raw pixels exactly + // like an inline image. This is the + // fast path that skips the client-side + // inflate the compressed-inline `t=d` + // fallback would force. A failed read + // just drops the frame — the sender + // reclaims its own object. + if let Some(img) = transfer.resolve() { + push_image_frame( + &mut frames, + img.encode_frame(), + ); + } + } + Segment::Delete(d) => { + frames.push(GraphicsFrame::Delete(d.encode())); + } + } + } + std::borrow::Cow::Owned(pass) + } + }; + let bytes: &[u8] = &passthrough; + // Sniff first (cheap, over the same bytes); collect any + // cwd/prompt change to emit while we hold the lock. let mut signals = sniffer.feed(bytes); if signals.shell.iter().any(|s| s.at_prompt) && foreground_running() { @@ -894,11 +1055,16 @@ impl DaemonPane { let mut st = state.lock().unwrap(); let facts_before = may_change_facts.then(|| observed_facts(&st)); st.ring.append(bytes); - fan_out_output(&mut st, bytes, &gate); + fan_out_output(&mut st, bytes, frames, &gate); apply_signals(&mut st, signals); if let Some(remote) = remote { apply_remote_context(&mut st, remote); } + // Keep kitty file/shm transfer gated on the pane's + // *current* locality: an `ssh` that just took the PTY + // must stop us honoring host-local object names. Cheap + // and only meaningful when a probe follows. + graphics.set_local(st.remote.is_none()); if let Some(agent) = agent { apply_agent(&mut st, agent); } @@ -1882,6 +2048,7 @@ fn proc_name(pid: i32) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::core::kitty_graphics::ImageDelete; use std::path::Path; #[test] @@ -2672,6 +2839,12 @@ mod tests { assert_eq!(d.shell.last().unwrap().last_exit_code, Some(-1)); } + /// A discard writer for `spawn_reader` tests that don't inspect the PTY + /// write-back path (graphics query replies). + fn null_writer() -> Arc>> { + Arc::new(Mutex::new(Box::new(std::io::sink()))) + } + fn test_state(alive: bool) -> PaneState { PaneState { id: 0, @@ -2775,6 +2948,7 @@ mod tests { Arc::new(AtomicBool::new(shutting_down)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(b"\x1b]133;D;0\x07".to_vec())), + null_writer(), || false, ForegroundProbes { remote: Box::new(|| None), @@ -3138,7 +3312,7 @@ mod tests { let chunk = vec![b'x'; 1024 * 1024]; let sends = (OBSERVER_BUDGET / chunk.len() as i64) as usize + 2; for _ in 0..sends { - fan_out_output(&mut st, &chunk, &pane_gate); + fan_out_output(&mut st, &chunk, Vec::new(), &pane_gate); pane_gate.sub(chunk.len()); } @@ -3178,7 +3352,7 @@ mod tests { let sends = (OBSERVER_BUDGET / chunk.len() as i64) as usize * 3; let mut got = 0usize; for _ in 0..sends { - fan_out_output(&mut st, &chunk, &pane_gate); + fan_out_output(&mut st, &chunk, Vec::new(), &pane_gate); pane_gate.sub(chunk.len()); while let Ok(DaemonMsg::Output(b)) = observer_rx.try_recv() { observer_gate.sub(b.len()); @@ -3282,6 +3456,7 @@ mod tests { Arc::new(AtomicBool::new(false)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(b"tail".to_vec())), + null_writer(), || false, ForegroundProbes { remote: Box::new(|| None), @@ -3307,6 +3482,131 @@ mod tests { ); } + /// Issue #213 end-to-end at the reader: a chunk carrying text plus a + /// kitty graphics query and a transmit-and-display must (1) keep only the + /// text in the replay ring and the `Output` frame, (2) write the `a=q` reply + /// back to the PTY writer, and (3) forward the image out-of-band as an + /// `Image` frame the client can decode. + #[test] + fn reader_strips_graphics_and_forwards_them_out_of_band() { + use crate::core::kitty_graphics::Image; + use base64::Engine as _; + + let state = Arc::new(Mutex::new(test_state(true))); + let (sub_tx, sub_rx) = mpsc::channel(); + state.lock().unwrap().subscriber = Some(sub_tx); + + // A writer we can read back, to prove the query reply reached the PTY. + #[derive(Clone)] + struct SharedBuf(Arc>>); + impl Write for SharedBuf { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(b); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let sink = Arc::new(Mutex::new(Vec::new())); + let writer: Arc>> = + Arc::new(Mutex::new(Box::new(SharedBuf(sink.clone())))); + + // One 1x1 opaque-red RGBA pixel, transmitted-and-displayed inline. + let pixel = [0xffu8, 0x00, 0x00, 0xff]; + let b64 = base64::engine::general_purpose::STANDARD.encode(pixel); + let stream = format!( + "before\x1b_Gi=1,a=q,t=d,f=32,s=1,v=1;AAAA\x1b\\\ + mid\x1b_Ga=T,f=32,t=d,s=1,v=1,i=1;{b64}\x1b\\after" + ); + + let handle = DaemonPane::spawn_reader( + state.clone(), + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + Box::new(std::io::Cursor::new(stream.into_bytes())), + writer, + || false, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ); + handle.join().unwrap(); + + // (1) The ring holds the text, none of the graphics bytes. + assert_eq!(state.lock().unwrap().ring.flatten(), b"beforemidafter"); + // (2) The query reply went to the PTY writer. + assert_eq!(sink.lock().unwrap().as_slice(), b"\x1b_Gi=1;OK\x1b\\"); + // (3) The subscriber sees the passthrough and the image *in stream order*: + // the text before the image, then the image at its cursor cell, then the + // text after it. The `a=q` reply splits "before" from "mid" into two + // Output frames; the image sits between "mid" and "after". + assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"before")); + assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"mid")); + match sub_rx.try_recv() { + Ok(DaemonMsg::Image(frame)) => { + let img = Image::decode_frame(&frame).expect("decodable image frame"); + assert_eq!(img.id, 1); + assert_eq!((img.width, img.height), (1, 1)); + assert_eq!(img.to_rgba8().unwrap(), pixel); + } + other => panic!("expected Image frame, got {other:?}"), + } + assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"after")); + } + + /// A kitty delete (`a=d`) is lifted out and forwarded as a `DeleteImage` + /// selector, leaving the surrounding text intact in the ring. + #[test] + fn reader_forwards_graphics_deletes() { + let state = Arc::new(Mutex::new(test_state(true))); + let (sub_tx, sub_rx) = mpsc::channel(); + state.lock().unwrap().subscriber = Some(sub_tx); + + let handle = DaemonPane::spawn_reader( + state.clone(), + Arc::new(AtomicBool::new(false)), + Arc::new(OutputGate::new()), + Box::new(std::io::Cursor::new(b"x\x1b_Ga=d,d=A\x1b\\y".to_vec())), + null_writer(), + || false, + ForegroundProbes { + remote: Box::new(|| None), + agent: Box::new(|| None), + cwd: Box::new(|| None), + }, + Arc::new(DeathReporter::new(|| {})), + ); + handle.join().unwrap(); + + assert_eq!(state.lock().unwrap().ring.flatten(), b"xy"); + // In stream order: text before the delete, the delete, then text after. + assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"x")); + match sub_rx.try_recv() { + Ok(DaemonMsg::DeleteImage(sel)) => { + let d = ImageDelete::decode(&sel).unwrap(); + assert_eq!(d.target, b'A'); + } + other => panic!("expected DeleteImage, got {other:?}"), + } + assert!(matches!(sub_rx.try_recv(), Ok(DaemonMsg::Output(b)) if b == b"y")); + } + + #[test] + fn push_image_frame_drops_over_max_frame() { + let mut frames = Vec::new(); + // At the limit: queued. + push_image_frame(&mut frames, vec![0u8; MAX_FRAME]); + // Over the limit: dropped, so the writer's fatal `write_frame` error + // (which would disconnect the client) is never reached. + push_image_frame(&mut frames, vec![0u8; MAX_FRAME + 1]); + assert_eq!(frames.len(), 1, "only the in-budget frame is queued"); + assert!(matches!(&frames[0], GraphicsFrame::Image(f) if f.len() == MAX_FRAME)); + } + #[test] fn reader_poll_applies_the_probed_cwd() { let state = Arc::new(Mutex::new(test_state(true))); @@ -3322,6 +3622,7 @@ mod tests { Arc::new(AtomicBool::new(false)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(b"alice@host ~ % ".to_vec())), + null_writer(), || false, ForegroundProbes { remote: Box::new(|| None), @@ -3352,6 +3653,7 @@ mod tests { Arc::new(AtomicBool::new(false)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(Vec::new())), + null_writer(), || false, ForegroundProbes { remote: Box::new(|| None), @@ -3377,6 +3679,7 @@ mod tests { Arc::new(AtomicBool::new(true)), Arc::new(OutputGate::new()), Box::new(std::io::Cursor::new(Vec::new())), + null_writer(), || false, ForegroundProbes { remote: Box::new(|| None), diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 2f84909d..bf31c37d 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -650,6 +650,18 @@ pub enum DaemonMsg { Size(WinSize), Snapshot(Vec), Output(Vec), + /// A kitty graphics image lifted out of the PTY stream daemon-side (issue + /// #213). Carried out-of-band as a compact binary frame + /// ([`crate::core::kitty_graphics::Image::encode_frame`]) so the base64 text + /// never rides the socket and the client's VT parser never sees it. The + /// pixel payload stays *compressed* on the wire — the client inflates — so a + /// remote pane's frames don't balloon across the SSH tunnel. + Image(Vec), + /// A kitty graphics delete (`a=d`) lifted out of the PTY stream daemon-side. + /// Payload is a compact selector frame + /// ([`crate::core::kitty_graphics::ImageDelete::encode`]) telling the client + /// which stored image(s)/placement(s) to drop. + DeleteImage(Vec), Cwd(PathBuf), Prompt { active: bool, @@ -745,6 +757,13 @@ mod kind { pub const VERSION_REPLY: u8 = 40; pub const PROCS: u8 = 50; pub const INPUT_ACK: u8 = 51; + /// `Image` — a kitty graphics frame lifted out of the PTY stream (issue + /// #213). 60 sits clear of every range above; the payload is the compact + /// binary encoding, not JSON, so it stays outside the `to_json` arms. + pub const IMAGE: u8 = 60; + /// `DeleteImage` — a kitty graphics `a=d` delete lifted out of the stream + /// (issue #213). Compact binary selector, like `IMAGE`. + pub const DELETE_IMAGE: u8 = 61; } pub fn write_frame(w: &mut W, kind: u8, payload: &[u8]) -> io::Result<()> { @@ -1058,6 +1077,8 @@ impl DaemonMsg { DaemonMsg::Size(size) => write_frame(w, kind::SIZE, &to_json(size)?), DaemonMsg::Snapshot(bytes) => write_frame(w, kind::SNAPSHOT, bytes), DaemonMsg::Output(bytes) => write_frame(w, kind::OUTPUT, bytes), + DaemonMsg::Image(frame) => write_frame(w, kind::IMAGE, frame), + DaemonMsg::DeleteImage(sel) => write_frame(w, kind::DELETE_IMAGE, sel), DaemonMsg::Cwd(path) => write_frame(w, kind::CWD, &to_json(path)?), DaemonMsg::Prompt { active, @@ -1112,6 +1133,8 @@ impl DaemonMsg { kind::SIZE => DaemonMsg::Size(from_json(&payload)?), kind::SNAPSHOT => DaemonMsg::Snapshot(payload), kind::OUTPUT => DaemonMsg::Output(payload), + kind::IMAGE => DaemonMsg::Image(payload), + kind::DELETE_IMAGE => DaemonMsg::DeleteImage(payload), kind::CWD => DaemonMsg::Cwd(from_json(&payload)?), kind::PROMPT => { let (active, at_prompt, last_exit) = from_json(&payload)?; diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 98de2333..dc930874 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -790,6 +790,10 @@ fn spawn_writer( }; let drained = match &msg { DaemonMsg::Output(b) => b.len(), + // Image frames are lifted from the same PTY read the gate + // credits, so they must debit it too or the reader stays + // throttled against bytes that already left the queue. + DaemonMsg::Image(b) => b.len(), _ => 0, }; let write_ok = msg.encode(&mut write_stream).is_ok(); diff --git a/src/terminal/element.rs b/src/terminal/element.rs index 912faff8..3fea8fed 100644 --- a/src/terminal/element.rs +++ b/src/terminal/element.rs @@ -7,10 +7,10 @@ use alacritty_terminal::selection::SelectionRange; use alacritty_terminal::term::cell::{Cell, Flags}; use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb}; use gpui::{ - App, BorderStyle, Bounds, ContentMask, CursorStyle, Element, ElementId, Font, FontStyle, - FontWeight, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, Hsla, IntoElement, LayoutId, - MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba, SharedString, - Style, TextAlign, TextRun, Window, fill, outline, point, px, relative, size, + App, BorderStyle, Bounds, ContentMask, Corners, CursorStyle, Element, ElementId, Font, + FontStyle, FontWeight, GlobalElementId, Hitbox, HitboxBehavior, HitboxId, Hsla, IntoElement, + LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Rgba, + SharedString, Style, TextAlign, TextRun, Window, fill, outline, point, px, relative, size, }; use gpui_component::ActiveTheme as _; @@ -823,6 +823,11 @@ struct GridSnapshot { any_selected: bool, any_match: bool, any_current: bool, + /// Scrollback state at snapshot time, so the paint pass can map a kitty + /// image's absolute anchor row back to a screen row: screen_row = + /// anchor_row - history_size + display_offset. + display_offset: i32, + history_size: usize, } impl TerminalElement { @@ -841,6 +846,7 @@ impl TerminalElement { let mut sliver: Option> = None; let mut any_selected = false; let display_offset; + let history_size; { let mut palette = self.view.read(cx).terminal.palette; if let Some(active) = cx.try_global::() { @@ -850,6 +856,7 @@ impl TerminalElement { let term = term.lock(); let content = term.renderable_content(); display_offset = content.display_offset as i32; + history_size = term.grid().history_size(); let selection = content.selection; for cell in content.display_iter { @@ -906,6 +913,8 @@ impl TerminalElement { any_selected, any_match, any_current, + display_offset, + history_size, } } @@ -1175,7 +1184,7 @@ impl Element for TerminalElement { .max(1.0) as usize; self.view.update(cx, |view, _cx| { - view.set_grid_size(cols, rows, cell_width, line_height); + view.set_grid_size(cols, rows, cell_width, line_height, window.scale_factor()); }); let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); @@ -1243,6 +1252,20 @@ impl Element for TerminalElement { ); let marked = self.view.read(cx).marked_text.clone(); + // Kitty-graphics placements for this pane. Each image anchors to an + // absolute scrollback row (recorded when its command arrived in-stream); + // map that back to a screen row with the snapshot's scroll state and + // drop anything scrolled out of the viewport. + let image_store = self.view.read(cx).terminal.images(); + // Take the retired list *before* the snapshot. The decode worker runs on + // its own thread and can retire a frame between the two calls; taking + // retired second would hand us a list containing an image the snapshot + // still says to paint, and `sprite_atlas.remove` takes effect + // immediately — so the frame would paint and then vanish. In this order + // the worst case is evicting one paint late, which is invisible. + let retired_images = image_store.take_retired(); + let images = image_store.snapshot(); + window.with_content_mask(Some(ContentMask { bounds }), |window| { paint_backgrounds(window, &geom, &buf); if snap.any_selected { @@ -1275,6 +1298,64 @@ impl Element for TerminalElement { bold_font.as_ref(), italic_font.as_ref(), ); + // Kitty-graphics images, painted over the placeholder cells they + // occupy. `anchor_row` is absolute (measured from the top of + // scrollback); convert it to a screen row with the same scroll state + // `build_grid` captured. Rows spanning past the viewport top/bottom + // are clipped by the surrounding content mask. + // + // Sizing: per the kitty spec, an image with no `c=`/`r=` is shown at + // its natural size — one image pixel per terminal *device* pixel. A + // sender like terminal-browser renders its frame to exactly fill the + // pixel area we reported to the child via `ws_xpixel`/`ws_ypixel`, + // which the daemon sets to `cols × round(cell_w × scale)` / + // `rows × round(cell_h × scale)` — the cell size in *device* pixels + // (see `set_grid_size`). So the frame is at device resolution; to map + // it back to a cell span we divide by that *same* device cell size, + // `round(cell_logical × scale)`. Painting the resulting cell-span + // bounds (in logical px) lets gpui blit the device-resolution bitmap + // ~1:1 on the framebuffer — sharp, and the right size — instead of + // upscaling a half-resolution one. Deriving here, not at placement, + // keeps it correct across font-size / zoom / display-scale changes. + let scale = window.scale_factor(); + let scale = if scale.is_finite() && scale > 0. { + scale + } else { + 1. + }; + for img in &images { + let round_w = (geom.cell_width.as_f32() * scale).round().max(1.); + let round_h = (geom.line_height.as_f32() * scale).round().max(1.); + let span_cols = if img.cols > 0 { + img.cols as f32 + } else { + (img.width_px as f32 / round_w).round().max(1.) + }; + let span_rows = if img.rows > 0 { + img.rows as f32 + } else { + (img.height_px as f32 / round_h).round().max(1.) + }; + let screen_row = + img.anchor_row - snap.history_size as i64 + snap.display_offset as i64; + // Fully above or below the viewport: nothing visible to paint. + if screen_row + span_rows as i64 <= 0 || screen_row >= geom.rows as i64 { + continue; + } + let top = geom.origin.y + geom.line_height * screen_row as f32; + let left = geom.origin.x + geom.cell_width * img.anchor_col as f32; + let bounds = Bounds { + origin: point(left, top), + size: size(geom.cell_width * span_cols, geom.line_height * span_rows), + }; + let _ = window.paint_image(bounds, Corners::default(), img.data.clone(), 0, false); + } + // Evict superseded / deleted frames from the sprite atlas. Without + // this a browser re-transmitting at 60fps would leak one GPU tile per + // frame, growing the atlas without bound until the compositor stalls. + for retired in &retired_images { + let _ = window.drop_image(retired.clone()); + } if let Some(row) = sliver { let sg = CellGeom { origin: point(geom.origin.x, geom.origin.y - geom.line_height), diff --git a/src/terminal/images.rs b/src/terminal/images.rs new file mode 100644 index 00000000..8205379b --- /dev/null +++ b/src/terminal/images.rs @@ -0,0 +1,573 @@ +//! Client-side kitty-graphics image store (issue #213). +//! +//! The daemon lifts image transmissions out of the PTY byte stream and forwards +//! them out-of-band as compact frames (`DaemonMsg::Image` / `DaemonMsg::DeleteImage` +//! — see [`tty7_core::core::kitty_graphics`]). This module is the client end: it +//! decodes each frame into a GPUI [`RenderImage`], anchors it to the grid cell the +//! cursor sat on when the command arrived, and hands the placed images to the +//! paint path so the element can blit them over the character grid. +//! +//! # Why anchor by absolute row +//! +//! Like a command [`mark`](crate::terminal::marks), an image's position has to +//! survive scrolling. A kitty image is placed at the cursor cell as it stood when +//! its command appeared in the stream; once recorded, the grid keeps scrolling +//! under it. We store the row as an absolute index from the top of scrollback +//! (`history_size - display_offset + cursor_line`, the exact formula +//! [`record_mark`](crate::terminal::remote) uses) and convert back to a screen +//! row at paint time (`anchor_row - history_size + display_offset`, the inverse +//! [`scroll_to_mark`](crate::terminal::view::TerminalView::scroll_to_mark) +//! applies). Below the scrollback limit — where a pane spends most of its life — +//! this is exact; past it the anchor drifts by the (unobservable) discard count, +//! the same caveat marks carry, and a browser that redraws every frame corrects +//! it on the next transmit anyway. +//! +//! GPUI's sprite atlas expects **BGRA** pixels (it swaps R↔B when caching an +//! `image` crate `RgbaImage` — see `gpui::img`), so [`decode`] does the swap once +//! at ingest; the placed [`RenderImage`] is uploaded verbatim thereafter. + +use std::sync::mpsc::{Receiver, Sender, TryRecvError, channel}; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; + +use gpui::RenderImage; +use image::{Frame, RgbaImage}; +use smallvec::SmallVec; + +use tty7_core::core::kitty_graphics::{Image, ImageDelete, WireFormat}; + +/// One image placed on the grid, ready to blit. +#[derive(Clone)] +pub struct PlacedImage { + /// Decoded pixels as a GPUI render image (BGRA, one frame). + pub data: Arc, + /// Row index from the top of scrollback at placement time — the position + /// anchor. See the module docs for when it stops being exact. + pub anchor_row: i64, + /// Column of the top-left cell. + pub anchor_col: usize, + /// Source pixel dimensions, for deriving the cell span when the sender did + /// not give an explicit one. + pub width_px: u32, + pub height_px: u32, + /// Explicit cell span the sender requested (`c=` / `r=`); 0 means "derive + /// from the pixel size and the cell size at paint time". + pub cols: u32, + pub rows: u32, + /// The kitty image id (`i=`) and placement id (`p=`), for targeted deletes. + /// `id == 0` is an anonymous image, removable only by a delete-all. + pub id: u32, + pub placement: u32, +} + +/// A pane's placed images plus the retired render images awaiting atlas +/// eviction, shared between the reader thread (writer) and the paint path +/// (reader), exactly like [`Marks`](crate::terminal::marks::Marks). +/// +/// `retired` is the other half of the fix for a browser that repaints at 60fps: +/// each transmitted frame becomes a fresh [`RenderImage`] with a new atlas id, +/// so the *previous* frame's GPU tile has to be dropped or the sprite atlas +/// grows without bound (see [`take_retired`](ImageStore::take_retired)). The +/// reader can't touch the atlas — that needs `&mut Window` — so it parks the +/// superseded `Arc`s here and the paint path drains and drops them. +#[derive(Default)] +struct StoreInner { + placed: Vec, + retired: Vec>, +} + +/// A pane's placed images, shared between the reader thread (writer) and the +/// paint path (reader), exactly like [`Marks`](crate::terminal::marks::Marks). +#[derive(Clone, Default)] +pub struct ImageStore(Arc>); + +/// Cap on placed images retained at once. A browser deletes-then-transmits every +/// frame, so the live set is tiny; this only bounds a sender that transmits +/// without ever deleting, dropping the oldest rather than growing without limit. +const MAX_IMAGES: usize = 256; + +impl ImageStore { + pub fn new() -> Self { + Self::default() + } + + /// Place a freshly received image at (`anchor_row`, `anchor_col`). A new + /// transmission with the same identity as an existing one replaces it in + /// place (kitty reuses an id to update an image); otherwise it is appended. + /// The replaced frame's render image is retired for atlas eviction. + pub fn place(&self, img: PlacedImage) { + let Ok(mut inner) = self.0.lock() else { return }; + let StoreInner { placed, retired } = &mut *inner; + // Same (id, placement) → replace. An anonymous image (id 0) never + // matches, so each anonymous transmit is a distinct placement. + if img.id != 0 { + placed.retain(|p| { + let same = p.id == img.id && p.placement == img.placement; + if same { + retired.push(p.data.clone()); + } + !same + }); + } + placed.push(img); + let overflow = placed.len().saturating_sub(MAX_IMAGES); + if overflow > 0 { + retired.extend(placed.drain(..overflow).map(|p| p.data)); + } + } + + /// Apply a delete selector. Only the targets a sender tty7 faces actually + /// uses are honored (all / by id / by placement); richer kitty selectors + /// (by cell, by z-index, by number) are left in place rather than guessed. + /// Removed frames' render images are retired for atlas eviction. + pub fn delete(&self, del: &ImageDelete) { + let Ok(mut inner) = self.0.lock() else { return }; + let retire = |p: &PlacedImage, keep: bool, retired: &mut Vec>| { + if !keep { + retired.push(p.data.clone()); + } + keep + }; + let StoreInner { placed, retired } = &mut *inner; + match del.target { + // All visible placements. Case only governs whether kitty also frees + // the image data; the client frees unconditionally, so both clear. + b'a' | b'A' => { + retired.extend(placed.drain(..).map(|p| p.data)); + } + // By image id. + b'i' | b'I' => placed.retain(|p| retire(p, p.id != del.id, retired)), + // By placement id (scoped to its image when an id is also given). + b'p' | b'P' => placed.retain(|p| { + let keep = p.placement != del.placement || (del.id != 0 && p.id != del.id); + retire(p, keep, retired) + }), + // A selector we don't model: leave the store untouched. + _ => {} + } + } + + /// Snapshot the placed images for a paint pass. Cheap: the live set is small + /// and `PlacedImage` is a handful of fields plus an `Arc` clone. + pub fn snapshot(&self) -> Vec { + self.0.lock().map(|s| s.placed.clone()).unwrap_or_default() + } + + /// Take the render images retired since the last call, for the paint path to + /// evict from the sprite atlas (`Window::drop_image`). Draining here — the + /// one place with `&mut Window` — is what stops a 60fps re-transmitting + /// sender from leaking a GPU tile per frame and dragging the compositor down. + pub fn take_retired(&self) -> Vec> { + self.0 + .lock() + .map(|mut s| std::mem::take(&mut s.retired)) + .unwrap_or_default() + } + + /// Drop everything (the grid was cleared, so every anchor is meaningless). + /// Placed frames are retired so their atlas tiles are still evicted. + pub fn clear(&self) { + if let Ok(mut inner) = self.0.lock() { + let gone: Vec<_> = inner.placed.drain(..).map(|p| p.data).collect(); + inner.retired.extend(gone); + } + } +} + +/// A raw (still-compressed) image frame handed to the [`DecodeWorker`], with the +/// grid anchor the reader captured the instant the transmission arrived. Keeping +/// the anchor with the frame lets decoding move off the reader thread without +/// losing the cursor position the image was drawn at. +pub struct PendingFrame { + pub img: Image, + pub anchor_row: i64, + pub anchor_col: usize, +} + +/// Off-thread image decoder with newest-frame-wins coalescing — the crux of the +/// performance story (issue #213). +/// +/// A full-window browser frame is ~28 MB of RGBA after zlib inflate, and the +/// inflate alone measures ~42 ms. Doing that inline on the reader thread (which +/// also services PTY output and scrolling) blocks the whole pane for ~42 ms per +/// frame, and a 60fps sender queues frames faster than they drain — latency +/// grows without bound and scrolling stutters. This is the cost the +/// device-pixel resolution bump quadrupled. +/// +/// The fix mirrors how kitty/ghostty stay smooth: decode off the I/O thread, and +/// when the producer outruns the decoder, **drop stale frames instead of +/// queuing them**. A bounded [`inbox`] of one *replaceable slot per image id* +/// means a re-transmitting browser only ever has its latest frame per image +/// waiting; older undecoded frames are discarded before they cost an inflate. +/// The worker decodes the newest, `place`s it, and wakes the view. +/// +/// [`inbox`]: DecodeWorker::inbox +pub struct DecodeWorker { + tx: Option>, + handle: Option>, +} + +impl DecodeWorker { + /// Spawn the decode thread. `store` is the shared placement store the worker + /// writes decoded frames into; `wake` is called after each successful decode + /// so the view repaints (a cloned `EventProxy::send_event(Wakeup)` in + /// practice). The thread ends when the returned worker is dropped (the + /// channel closes). + pub fn spawn(store: ImageStore, wake: impl Fn() + Send + 'static) -> Self { + let (tx, rx) = channel::(); + let handle = std::thread::Builder::new() + .name("tty7-image-decode".to_string()) + .spawn(move || Self::run(rx, store, wake)) + .ok(); + Self { + tx: Some(tx), + handle, + } + } + + /// Hand a raw frame to the worker. Never blocks the caller (the reader + /// thread): the frame is queued and decoded asynchronously. If the worker + /// has gone away the frame is silently dropped — the pane is tearing down. + pub fn submit(&self, frame: PendingFrame) { + if let Some(tx) = &self.tx { + let _ = tx.send(frame); + } + } + + /// The decode loop. Blocks for the next frame, then **coalesces**: drains + /// everything already queued and keeps only the last frame per image id, so + /// a burst that piled up during a slow inflate collapses to one decode per + /// image. Decodes the survivors newest-first and places them. + fn run(rx: Receiver, store: ImageStore, wake: impl Fn()) { + while let Ok(first) = rx.recv() { + // Collect the blocking frame plus any that arrived while we were + // busy, newest-per-id winning (a later frame with the same id + // supersedes an earlier one — exactly what `place` would do, but + // without paying to decode the ones we'd immediately retire). + let mut latest: Vec = vec![first]; + loop { + match rx.try_recv() { + Ok(next) => { + if next.img.id != 0 + && let Some(slot) = latest.iter_mut().find(|p| p.img.id == next.img.id) + { + *slot = next; // supersede the queued frame for this id + } else { + latest.push(next); + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + // Decode whatever we already gathered, then exit. + Self::place_all(&store, latest, &wake); + return; + } + } + } + Self::place_all(&store, latest, &wake); + } + } + + fn place_all(store: &ImageStore, frames: Vec, wake: &impl Fn()) { + let mut placed_any = false; + for pf in frames { + if let Some((data, w, h)) = decode(&pf.img) { + store.place(PlacedImage { + data, + anchor_row: pf.anchor_row, + anchor_col: pf.anchor_col, + width_px: w, + height_px: h, + cols: pf.img.cols, + rows: pf.img.rows, + id: pf.img.id, + placement: pf.img.placement, + }); + placed_any = true; + } + } + if placed_any { + wake(); + } + } +} + +impl Drop for DecodeWorker { + fn drop(&mut self) { + // Drop the sender first so the worker sees `Disconnected` and returns; + // then join so its store writes are visible and it doesn't outlive the + // pane. Joining before dropping `tx` would deadlock — the loop only ends + // once the channel closes. + self.tx = None; + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} + +/// Decode a daemon [`Image`] frame into GPUI-ready pixels: inflate + expand to +/// RGBA (via the protocol type's own [`Image::to_rgba8`]), or decode a PNG +/// payload, then swap R↔B to the BGRA the sprite atlas wants. Returns the render +/// image and its true pixel dimensions (a PNG carries its own, overriding any +/// `s=`/`v=` the sender may have omitted). `None` if the payload can't be decoded. +pub fn decode(img: &Image) -> Option<(Arc, u32, u32)> { + let (mut rgba, w, h) = match img.format { + WireFormat::Png => { + // The one path pixels stay encoded through: decode with the `image` + // crate (a direct dep, same version gpui uses) rather than the + // protocol type, which declines PNG on purpose. + let dyn_img = image::load_from_memory(&img.data).ok()?; + let buf = dyn_img.into_rgba8(); + let (w, h) = buf.dimensions(); + (buf.into_raw(), w, h) + } + WireFormat::Rgb | WireFormat::Rgba => { + let rgba = img.to_rgba8()?; + (rgba, img.width, img.height) + } + }; + if w == 0 || h == 0 || rgba.len() < (w as usize * h as usize * 4) { + return None; + } + rgba.truncate(w as usize * h as usize * 4); + // RGBA → BGRA for the atlas. A channel swap and nothing else: `RenderImage` + // holds *straight* alpha, not premultiplied. gpui's own producers say so — + // `swap_rgba_pa_to_bgra`, which both the CoreGraphics text rasterizer and + // the SVG renderer run their premultiplied output through, divides the + // color channels back out by alpha on the way in. Premultiplying here would + // darken every translucent pixel of an `f=100` PNG twice over. + for px in rgba.chunks_exact_mut(4) { + px.swap(0, 2); + } + let buffer = RgbaImage::from_raw(w, h, rgba)?; + let render = RenderImage::new(SmallVec::from_elem(Frame::new(buffer), 1)); + Some((Arc::new(render), w, h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A raw 1x1 opaque-red RGBA image, the minimum a placement needs, built the + /// way the daemon delivers one (base64-decoded, uncompressed). + fn red_pixel() -> Image { + Image { + id: 0, + number: 0, + placement: 0, + width: 1, + height: 1, + cols: 0, + rows: 0, + data: vec![0xff, 0x00, 0x00, 0xff], + format: WireFormat::Rgba, + compressed: false, + } + } + + fn placed(id: u32, placement: u32) -> PlacedImage { + let (data, w, h) = decode(&red_pixel()).unwrap(); + PlacedImage { + data, + anchor_row: 0, + anchor_col: 0, + width_px: w, + height_px: h, + cols: 0, + rows: 0, + id, + placement, + } + } + + #[test] + fn decodes_rgba_and_swaps_to_bgra() { + let img = Image { + data: vec![1, 2, 3, 4], + ..red_pixel() + }; + let (data, w, h) = decode(&img).unwrap(); + assert_eq!((w, h), (1, 1)); + // Red/blue swapped: RGBA [1,2,3,4] → BGRA [3,2,1,4]. + assert_eq!(data.as_bytes(0).unwrap(), &[3, 2, 1, 4]); + } + + #[test] + fn same_id_replaces_in_place() { + let store = ImageStore::new(); + store.place(placed(7, 1)); + store.place(placed(7, 1)); + assert_eq!( + store.snapshot().len(), + 1, + "a re-transmit replaces, not stacks" + ); + } + + #[test] + fn anonymous_images_coexist() { + let store = ImageStore::new(); + store.place(placed(0, 0)); + store.place(placed(0, 0)); + assert_eq!( + store.snapshot().len(), + 2, + "id 0 is a fresh placement each time" + ); + } + + #[test] + fn delete_all_clears_everything() { + let store = ImageStore::new(); + store.place(placed(1, 0)); + store.place(placed(2, 0)); + store.delete(&ImageDelete { + target: b'A', + id: 0, + placement: 0, + }); + assert!(store.snapshot().is_empty()); + } + + #[test] + fn delete_by_id_leaves_others() { + let store = ImageStore::new(); + store.place(placed(1, 0)); + store.place(placed(2, 0)); + store.delete(&ImageDelete { + target: b'i', + id: 1, + placement: 0, + }); + let left = store.snapshot(); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, 2); + } + + #[test] + fn unknown_selector_is_a_no_op() { + let store = ImageStore::new(); + store.place(placed(1, 0)); + // `z` (by z-index) isn't modeled; the image must survive. + store.delete(&ImageDelete { + target: b'z', + id: 0, + placement: 0, + }); + assert_eq!(store.snapshot().len(), 1); + } + + #[test] + fn overflow_drops_the_oldest() { + let store = ImageStore::new(); + for i in 0..(MAX_IMAGES as u32 + 5) { + store.place(placed(i + 1, 0)); + } + let left = store.snapshot(); + assert_eq!(left.len(), MAX_IMAGES); + assert_eq!(left[0].id, 6, "the five oldest aged out"); + } + + #[test] + fn replacing_a_frame_retires_the_old_render_image() { + let store = ImageStore::new(); + store.place(placed(7, 1)); + // A same-id re-transmit (what a 60fps browser does) must retire the old + // frame's render image so the paint path can evict its atlas tile. + store.place(placed(7, 1)); + assert_eq!(store.snapshot().len(), 1, "still one live placement"); + assert_eq!( + store.take_retired().len(), + 1, + "the superseded frame is retired" + ); + assert!(store.take_retired().is_empty(), "draining is one-shot"); + } + + #[test] + fn deletes_and_clear_retire_render_images() { + let store = ImageStore::new(); + store.place(placed(1, 0)); + store.place(placed(2, 0)); + let _ = store.take_retired(); // drain the (none) from placement + store.delete(&ImageDelete { + target: b'i', + id: 1, + placement: 0, + }); + assert_eq!( + store.take_retired().len(), + 1, + "the deleted frame is retired" + ); + store.clear(); + assert_eq!( + store.take_retired().len(), + 1, + "clear retires the survivor too" + ); + } + + fn frame(id: u32) -> PendingFrame { + PendingFrame { + img: Image { id, ..red_pixel() }, + anchor_row: 0, + anchor_col: 0, + } + } + + /// The worker decodes off-thread and places what it receives. A single frame + /// lands in the store, at the anchor the reader captured. + #[test] + fn worker_decodes_and_places_off_thread() { + let store = ImageStore::new(); + let woken = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let w = woken.clone(); + let worker = DecodeWorker::spawn(store.clone(), move || { + w.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }); + worker.submit(frame(7)); + drop(worker); // joins the thread, so the decode is finished on return + let placed = store.snapshot(); + assert_eq!(placed.len(), 1); + assert_eq!(placed[0].id, 7); + assert!( + woken.load(std::sync::atomic::Ordering::SeqCst) >= 1, + "a successful decode wakes the view" + ); + } + + /// A burst of same-id frames (what a re-transmitting browser produces faster + /// than the decoder drains) collapses to a single live placement — stale + /// frames are coalesced away rather than each costing an inflate. The store's + /// own same-id replacement guarantees the end state even if the worker only + /// sees them one at a time, so this asserts the invariant the pipeline keeps. + #[test] + fn worker_coalesces_a_same_id_burst_to_one_placement() { + let store = ImageStore::new(); + let worker = DecodeWorker::spawn(store.clone(), || {}); + for _ in 0..50 { + worker.submit(frame(9)); + } + drop(worker); // drains + joins + assert_eq!( + store.snapshot().len(), + 1, + "a same-id burst leaves exactly one live frame" + ); + } + + /// Distinct ids are independent placements — coalescing is per id, so two + /// different images both survive. + #[test] + fn worker_keeps_distinct_ids() { + let store = ImageStore::new(); + let worker = DecodeWorker::spawn(store.clone(), || {}); + worker.submit(frame(1)); + worker.submit(frame(2)); + drop(worker); + let mut ids: Vec = store.snapshot().iter().map(|p| p.id).collect(); + ids.sort_unstable(); + assert_eq!(ids, vec![1, 2]); + } +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index cb0c3e84..52ef2293 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -10,6 +10,7 @@ pub(crate) mod git_status; mod highlight; mod history; mod hold; +pub(crate) mod images; pub mod input; mod loopback; pub(crate) mod marks; diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 4c84f221..0cc8a01c 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -76,6 +76,10 @@ struct ReaderSignals { auth: Arc>>, phase: Arc>>, marks: crate::terminal::marks::Marks, + /// Kitty-graphics images the daemon lifted out of the stream (issue #213), + /// anchored to the grid for the paint path to blit. Shared with the reader, + /// which places/deletes them as `DaemonMsg::Image`/`DeleteImage` frames land. + images: crate::terminal::images::ImageStore, } #[derive(Clone, Debug, PartialEq)] @@ -152,6 +156,10 @@ pub struct RemoteTerminal { pub exited: bool, size: TermSize, synced_size: bool, + /// The `(cell_w, cell_h)` last sent to the daemon, in device pixels. Tracked + /// alongside `size` so a display-scale change still reaches the child even + /// when the grid dimensions are unchanged. + synced_cell: (u16, u16), writer: Mutex, cwd: Arc>>, shell_state: Arc>, @@ -167,6 +175,11 @@ pub struct RemoteTerminal { agent: Arc>>, agent_session: Arc>>, marks: crate::terminal::marks::Marks, + /// Kitty-graphics images placed on this pane's grid (issue #213). + /// Written by the reader thread from out-of-band `Image`/`DeleteImage` + /// frames, read by the paint path — same shared-handle discipline as + /// `marks`, since only the client holds the grid the anchors are relative to. + images: crate::terminal::images::ImageStore, route: PaneRoute, proxy: EventProxy, reader_thread: Option>, @@ -334,6 +347,10 @@ impl RemoteTerminal { let mut term = self.term.lock(); term.reset_state(); } + // The grid was just reset, so every image anchor now points nowhere. + // Drop them; the daemon does not replay out-of-band image frames, so a + // browser redraws on its next transmit (see issue #213's reattach note). + self.images.clear(); let reader = Self::spawn_reader( self.term.clone(), @@ -353,6 +370,7 @@ impl RemoteTerminal { auth: self.auth_prompts.clone(), phase: self.ssh_phase.clone(), marks: self.marks.clone(), + images: self.images.clone(), }, ); if let Ok(mut writer) = self.writer.lock() { @@ -401,6 +419,7 @@ impl RemoteTerminal { Arc::new(Mutex::new(VecDeque::new())); let ssh_phase: Arc>> = Arc::new(Mutex::new(None)); let marks = crate::terminal::marks::Marks::new(); + let images = crate::terminal::images::ImageStore::new(); let reader_thread = Self::spawn_reader( term.clone(), @@ -420,6 +439,7 @@ impl RemoteTerminal { auth: auth_prompts.clone(), phase: ssh_phase.clone(), marks: marks.clone(), + images: images.clone(), }, ); @@ -430,6 +450,7 @@ impl RemoteTerminal { exited: false, size, synced_size: false, + synced_cell: (0, 0), writer: Mutex::new(write_half), cwd, shell_state, @@ -445,6 +466,7 @@ impl RemoteTerminal { agent, agent_session, marks, + images, route: PaneRoute::Local, proxy, reader_thread: Some(reader_thread), @@ -490,6 +512,7 @@ impl RemoteTerminal { auth, phase, marks, + images, } = signals; crate::core::threads::promote_to_user_interactive(); let mut stream = read_half; @@ -500,6 +523,19 @@ impl RemoteTerminal { let mut mark_scan = MarkScanner::new(); let mut pending: Vec = buffered; let mut pending_size: Option = None; + // Kitty-graphics decode runs on its own thread with newest-frame + // coalescing (issue #213): inflating a full-window browser frame + // is ~42 ms, and doing it inline here would block PTY output and + // scrolling for that long every frame. The worker owns the inflate + // + BGRA swap + placement; the reader only captures the grid + // anchor and hands off the still-compressed frame. Dropped when + // the loop ends, which joins the thread. + let image_decoder = { + let proxy = proxy.clone(); + crate::terminal::images::DecodeWorker::spawn(images.clone(), move || { + proxy.send_event(AlacEvent::Wakeup); + }) + }; let mut scratch = vec![0u8; 256 * 1024]; let trace = std::env::var("TTY7_TRACE").is_ok_and(|v| !v.is_empty() && v != "0"); @@ -634,6 +670,59 @@ impl RemoteTerminal { out_batch.extend_from_slice(&bytes); tr_frames += 1; } + // Kitty graphics (issue #213): the daemon lifted an + // image out of the stream and forwarded it out-of-band, + // interleaved *in stream order* with the Output frames + // around it. Flush the pending text first so the grid + // cursor sits where the sender drew the image, then + // anchor the placement to that cell in scroll-stable + // absolute-row coordinates (the same formula + // `record_mark` uses), so it tracks scrolling. + DaemonMsg::Image(frame) => { + flush_batch!(); + if let Some(img) = + tty7_core::core::kitty_graphics::Image::decode_frame(&frame) + { + // Capture the anchor *now*, at the cursor cell + // the transmission arrived on; the decode is + // deferred to the worker thread but must land + // at this position, not wherever the cursor has + // scrolled to by the time inflate finishes. + let (anchor_row, anchor_col) = { + use alacritty_terminal::grid::Dimensions as _; + let term = term.lock(); + let grid = term.grid(); + let row = grid.history_size() as i64 + - grid.display_offset() as i64 + + i64::from(grid.cursor.point.line.0); + (row, grid.cursor.point.column.0) + }; + // Hand off without blocking the reader: the + // worker inflates, swaps, places, and wakes the + // view. Stale frames coalesce away there. + image_decoder.submit( + crate::terminal::images::PendingFrame { + img, + anchor_row, + anchor_col, + }, + ); + } + } + // An `a=d` delete, lifted out the same way. Order with + // the surrounding output does not matter for a delete + // (it targets by id/placement, not cursor position), + // but flushing keeps a delete-then-retransmit in the + // same read from racing its own replacement. + DaemonMsg::DeleteImage(sel) => { + flush_batch!(); + if let Some(del) = + tty7_core::core::kitty_graphics::ImageDelete::decode(&sel) + { + images.delete(&del); + proxy.send_event(AlacEvent::Wakeup); + } + } DaemonMsg::Cwd(path) => { flush_batch!(); if let Ok(mut guard) = cwd.lock() { @@ -799,7 +888,13 @@ impl RemoteTerminal { } pub fn resize(&mut self, size: TermSize, cell_w: u16, cell_h: u16) { - if self.synced_size && size == self.size { + // The cell size has to be part of the early-out, not just cols/rows: it + // is reported in *device* pixels, so moving the window between a 2x and + // a 1x display changes `ws_xpixel`/`ws_ypixel` while the grid stays + // exactly the same. Comparing only `size` there would skip the resize + // and leave a pixel-aware child rendering for the old framebuffer. + let cell = (cell_w, cell_h); + if self.synced_size && size == self.size && cell == self.synced_cell { use alacritty_terminal::grid::Dimensions as _; let term = self.term.lock(); if term.columns() == size.cols && term.screen_lines() == size.rows { @@ -808,6 +903,7 @@ impl RemoteTerminal { } self.synced_size = true; self.size = size; + self.synced_cell = cell; self.term.lock().resize(size); let win = win_size(size, cell_w, cell_h); @@ -855,6 +951,13 @@ impl RemoteTerminal { self.marks.clone() } + /// The kitty-graphics image store for this pane. Cheap handle clone — the + /// store is an `Arc>` shared with the reader thread, which places + /// and deletes images as out-of-band frames arrive from the daemon. + pub fn images(&self) -> crate::terminal::images::ImageStore { + self.images.clone() + } + pub fn agent_session(&self) -> Option { self.agent_session.lock().ok().and_then(|g| g.clone()) } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index f270fd37..0f3ffda9 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -772,6 +772,7 @@ impl TerminalView { rows: usize, cell_width: Pixels, line_height: Pixels, + scale: f32, ) { if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) { self.last_hover_cell = None; @@ -779,10 +780,23 @@ impl TerminalView { } self.cell_width = cell_width; self.line_height = line_height; + // Report the cell size to the child in *device* pixels (logical × display + // scale), so `ws_xpixel`/`ws_ypixel` describe the real framebuffer. A + // pixel-aware program like terminal-browser renders its frame at that + // native resolution; painted back into logical-pixel bounds, gpui blits + // it ~1:1 on the framebuffer instead of upscaling a half-resolution + // bitmap (which looked soft and magnified on Retina). This is what + // kitty/ghostty report. `self.cell_width` stays logical — glyph layout + // and mouse mapping work in logical pixels. + let scale = if scale.is_finite() && scale > 0. { + scale + } else { + 1. + }; self.terminal.resize( TermSize::new(cols, rows), - cell_width.as_f32().round() as u16, - line_height.as_f32().round() as u16, + (cell_width.as_f32() * scale).round().max(1.) as u16, + (line_height.as_f32() * scale).round().max(1.) as u16, ); } @@ -2472,6 +2486,10 @@ impl TerminalView { None } + fn link_inactive_reason(&self, cx: &gpui::App) -> Option<&'static str> { + (!self.accepts_input(cx)).then_some("the remote link is not attached") + } + fn shell_vi_prompt(&self) -> bool { self.terminal.shell_vi_mode() && self.terminal.at_prompt() && !self.on_alt_screen() } @@ -2596,7 +2614,7 @@ impl TerminalView { } fn submit_command(&mut self, cx: &mut Context) { - if self.terminal.exited { + if self.terminal.exited || !self.accepts_input(cx) { return; } if let Some(net) = self.hold.engage() { @@ -2855,6 +2873,9 @@ impl TerminalView { } fn handoff_line_to_shell(&mut self, chord: &[u8], cx: &mut Context) { + if !self.accepts_input(cx) { + return; + } if let Some(net) = self.hold.engage() { self.cmd.prepend_str(&net); } @@ -2882,6 +2903,10 @@ impl TerminalView { cx.propagate(); return; } + if let Some(reason) = self.link_inactive_reason(cx) { + log::debug!(target: "tty7::completion", "Tab does nothing and the line stays: {reason}"); + return; + } if let Some(reason) = self.input_inactive_reason() { log::debug!(target: "tty7::completion", "Tab goes straight to the PTY: {reason}"); let bytes = self.tab_bytes(!forward); @@ -3040,19 +3065,16 @@ impl TerminalView { log::debug!(target: "tty7::completion", "listing {dir} over the remote's own connection"); cx.spawn(async move |this, cx| { let listed = cx.background_spawn(async move { route.list(&dir) }).await; - if let Err(e) = &listed { - log::warn!(target: "tty7::completion", "remote listing failed: {e}"); - } + let entries = listed.unwrap_or_else(|e| { + log::warn!( + target: "tty7::completion", + "remote listing failed, treating it as no candidates: {e}" + ); + Vec::new() + }); let _ = this.update(cx, |view, cx| { view.remote_completion_inflight = false; - view.remote_path_results( - req, - &line, - cursor, - listed.unwrap_or_default(), - forward, - cx, - ) + view.remote_path_results(req, &line, cursor, entries, forward, cx); }); }) .detach(); @@ -3068,6 +3090,16 @@ impl TerminalView { forward: bool, cx: &mut Context, ) { + if let Some(reason) = self + .link_inactive_reason(cx) + .or_else(|| self.input_inactive_reason()) + { + log::debug!( + target: "tty7::completion", + "dropping a remote listing for {line:?}: {reason}" + ); + return; + } if self.cmd.text() != line || self.cmd.cursor() != cursor { log::debug!( target: "tty7::completion", @@ -5680,16 +5712,16 @@ mod gpui_tests { let (window, _daemon) = harness(cx); window .update(cx, |view, _, cx| { - view.set_grid_size(80, 24, px(8.), px(17.)); + view.set_grid_size(80, 24, px(8.), px(17.), 1.); view.hover_link_at(0, 23, true, cx); assert_eq!(view.last_hover_cell, Some((0, 23))); view.hovered_link = Some(HoveredLink { start: Point::new(Line(23), Column(0)), end: Point::new(Line(23), Column(3)), }); - view.set_grid_size(80, 24, px(8.), px(17.)); + view.set_grid_size(80, 24, px(8.), px(17.), 1.); assert_eq!(view.last_hover_cell, Some((0, 23))); - view.set_grid_size(80, 8, px(8.), px(17.)); + view.set_grid_size(80, 8, px(8.), px(17.), 1.); assert!(view.last_hover_cell.is_none(), "the cell is stale"); assert!(view.hovered_link.is_none(), "so is the link it resolved"); }) @@ -5940,6 +5972,45 @@ mod gpui_tests { wait_for_input_active(&window, cx); } + #[gpui::test] + fn a_late_remote_listing_leaves_a_line_the_editor_no_longer_owns_alone( + cx: &mut TestAppContext, + ) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, _, cx| { + view.cmd.set("ls /nope/"); + view.editor_handoff = Some(view.terminal.prompt_cycle()); + assert!(!view.input_active(), "the shell owns this prompt already"); + + let req = + super::completion::remote_path_request("ls /nope/", 9, "/home/u").unwrap(); + view.remote_path_results(req, "ls /nope/", 9, Vec::new(), true, cx); + + assert_eq!( + view.cmd.text(), + "ls /nope/", + "an empty listing must not hand off a line the editor no longer drives" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "not one byte reached the wire" + ); + } + #[gpui::test] fn tab_completion_off_sends_every_tab_to_the_shell(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx); @@ -6931,6 +7002,111 @@ mod gpui_tests { id } + #[gpui::test] + fn a_disconnected_remote_pane_keeps_the_line_instead_of_handing_it_to_nowhere( + cx: &mut TestAppContext, + ) { + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + cx.update(|cx| crate::ui::keymap::init(cx)); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + wait_for_input_active(&window, cx); + + window + .update(cx, |view, window, cx| { + window.activate_window(); + view.focus_handle.focus(window, cx); + view.cmd.set("zzqqx"); + bind_to_a_disconnected_remote_workspace(view, cx); + }) + .unwrap(); + + let mut vcx = gpui::VisualTestContext::from_window(window.into(), cx); + vcx.simulate_keystrokes("tab"); + + window + .update(cx, |view, _, cx| { + assert_eq!( + view.cmd.text(), + "zzqqx", + "a Tab dispatched through SendTab must not empty the line" + ); + assert!( + view.editor_handoff.is_none(), + "nothing was handed off, so the editor keeps the prompt" + ); + + view.submit_command(cx); + assert_eq!( + view.cmd.text(), + "zzqqx", + "submit_command guards the link too, even though on_key_down already does" + ); + }) + .unwrap(); + assert_eq!( + next_input_until_timeout(&mut daemon), + None, + "not one byte reached the wire" + ); + } + + #[gpui::test] + fn a_tab_on_a_detached_remote_pane_never_asks_for_a_listing(cx: &mut TestAppContext) { + use std::io::Write as _; + crate::core::config::pin_test_config_dir(); + let (window, mut daemon) = harness(cx); + DaemonMsg::Prompt { + active: true, + at_prompt: true, + last_exit: None, + } + .encode(&mut daemon) + .unwrap(); + DaemonMsg::Cwd(std::path::PathBuf::from("/home/me/proj")) + .encode(&mut daemon) + .unwrap(); + daemon.flush().unwrap(); + wait_for_input_active(&window, cx); + for _ in 0..200 { + if window + .update(cx, |view, _, _| view.cwd().is_some()) + .unwrap() + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + + window + .update(cx, |view, _, cx| { + view.cmd.set("ls /home/me/"); + bind_to_a_disconnected_remote_workspace(view, cx); + assert!( + view.remote_ssh_cwd().is_some(), + "the pane has to look remote enough to want a listing at all" + ); + + view.tab_pressed(true, cx); + assert!( + !view.remote_completion_inflight, + "a Tab must not send an SFTP listing down a link that is not attached" + ); + assert_eq!( + view.cmd.text(), + "ls /home/me/", + "and the line stays where it was" + ); + }) + .unwrap(); + } + #[gpui::test] fn a_disconnected_remote_pane_swallows_every_kind_of_typing(cx: &mut TestAppContext) { let (window, mut daemon) = harness(cx);