aboutsummaryrefslogtreecommitdiff
path: root/src/client/direct_graphics.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-09 02:04:39 +0300
committerGitHub <noreply@github.com>2026-08-09 02:04:39 +0300
commit1777e9bba32b953ed1ad203b4a16d01105539000 (patch)
tree22096973812c9baba822453d8470bdcfe64fc2a6 /src/client/direct_graphics.rs
parent36a8d36a7258e08b40c01bf521b1f4006525f143 (diff)
feat(graphics): add direct pane frame streaming (#2523)
* feat(graphics): add direct pane frame streaming * feat(graphics): expose pane visibility * test(graphics): preserve legacy terminal image output * fix(graphics): harden streaming lifecycle * fix(input): preserve mouse releases outside panes
Diffstat (limited to 'src/client/direct_graphics.rs')
-rw-r--r--src/client/direct_graphics.rs393
1 files changed, 393 insertions, 0 deletions
diff --git a/src/client/direct_graphics.rs b/src/client/direct_graphics.rs
new file mode 100644
index 00000000..1bf10041
--- /dev/null
+++ b/src/client/direct_graphics.rs
@@ -0,0 +1,393 @@
+const MAX_RESPONSE_BYTES: usize = 4096;
+const KITTY_PREFIX: &[u8] = b"\x1b_G";
+const KITTY_SUFFIX: &[u8] = b"\x1b\\";
+const RESPONSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
+const LATE_RESPONSE_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
+
+#[derive(Debug, PartialEq, Eq)]
+pub(super) struct Response {
+ pub(super) transfer_id: u64,
+ pub(super) image_id: u32,
+ pub(super) success: bool,
+}
+
+#[derive(Debug, Default)]
+pub(super) struct ResponseMatcher {
+ expected: Option<(u64, u32, Option<std::time::Instant>)>,
+ retired: Option<(u32, std::time::Instant)>,
+ active: std::sync::Arc<std::sync::atomic::AtomicBool>,
+}
+
+impl ResponseMatcher {
+ pub(super) fn active_handle(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
+ self.active.clone()
+ }
+
+ fn refresh_active(&self) {
+ self.active.store(
+ self.expected.is_some() || self.retired.is_some(),
+ std::sync::atomic::Ordering::Release,
+ );
+ }
+ pub(super) fn interested(&mut self) -> bool {
+ self.expire();
+ self.expected.is_some() || self.retired.is_some()
+ }
+ pub(super) fn arm(&mut self, transfer_id: u64, image_id: u32) -> bool {
+ self.expire();
+ if self.expected.is_some() {
+ return false;
+ }
+ self.expected = Some((transfer_id, image_id, None));
+ self.refresh_active();
+ true
+ }
+
+ pub(super) fn start(&mut self, transfer_id: u64) {
+ self.start_at(transfer_id, std::time::Instant::now());
+ }
+
+ fn start_at(&mut self, transfer_id: u64, now: std::time::Instant) {
+ if let Some((id, _, deadline)) = &mut self.expected {
+ if *id == transfer_id {
+ *deadline = Some(now + RESPONSE_TIMEOUT);
+ }
+ }
+ }
+
+ pub(super) fn cancel(&mut self, transfer_id: u64) {
+ if self.expected.is_some_and(|(id, _, _)| id == transfer_id) {
+ self.expected = None;
+ self.refresh_active();
+ }
+ }
+
+ pub(super) fn retire(&mut self, transfer_id: u64) {
+ if self.expected.is_some_and(|(id, _, _)| id == transfer_id) {
+ if let Some((_, image_id, _)) = self.expected.take() {
+ self.retired = Some((image_id, std::time::Instant::now() + LATE_RESPONSE_DRAIN));
+ self.refresh_active();
+ }
+ }
+ }
+
+ pub(super) fn expire(&mut self) {
+ self.expire_at(std::time::Instant::now());
+ }
+
+ fn expire_at(&mut self, now: std::time::Instant) {
+ if self
+ .expected
+ .is_some_and(|(_, _, deadline)| deadline.is_some_and(|deadline| deadline <= now))
+ {
+ if let Some((_, image_id, _)) = self.expected.take() {
+ self.retired = Some((image_id, now + LATE_RESPONSE_DRAIN));
+ }
+ }
+ if self.retired.is_some_and(|(_, deadline)| deadline <= now) {
+ self.retired = None;
+ }
+ self.refresh_active();
+ }
+
+ pub(super) fn consume(&mut self, bytes: &[u8]) -> Option<Option<Response>> {
+ self.expire();
+ if !bytes.starts_with(b"\x1b_G")
+ || bytes.len() > MAX_RESPONSE_BYTES
+ || !bytes.ends_with(b"\x1b\\")
+ {
+ return None;
+ }
+ let payload = &bytes[3..bytes.len() - 2];
+ let separator = payload.iter().position(|byte| *byte == b';')?;
+ if let Some((retired_id, _)) = self.retired {
+ if matching_response_controls(&payload[..separator], retired_id) {
+ self.retired = None;
+ self.refresh_active();
+ return Some(None);
+ }
+ }
+ let (transfer_id, image_id, _) = self.expected?;
+ if !matching_response_controls(&payload[..separator], image_id) {
+ return None;
+ }
+ self.expected = None;
+ self.refresh_active();
+ Some(Some(Response {
+ transfer_id,
+ image_id,
+ success: &payload[separator + 1..] == b"OK",
+ }))
+ }
+}
+
+#[derive(Default)]
+pub(super) struct InputFilter {
+ pending: Vec<u8>,
+}
+
+impl InputFilter {
+ pub(super) fn has_pending(&self) -> bool {
+ !self.pending.is_empty()
+ }
+
+ pub(super) fn flush_if_inactive(&mut self, matcher: &mut ResponseMatcher) -> Option<Vec<u8>> {
+ (self.has_pending() && !matcher.interested()).then(|| std::mem::take(&mut self.pending))
+ }
+
+ pub(super) fn push(
+ &mut self,
+ bytes: &[u8],
+ matcher: &mut ResponseMatcher,
+ ) -> (Vec<Vec<u8>>, Vec<Response>) {
+ self.pending.extend_from_slice(bytes);
+ let mut output = Vec::new();
+ let mut responses = Vec::new();
+ if !matcher.interested() {
+ output.push(std::mem::take(&mut self.pending));
+ return (output, responses);
+ }
+ loop {
+ let Some(start) = self
+ .pending
+ .windows(3)
+ .position(|window| window == KITTY_PREFIX)
+ else {
+ let keep = (1..KITTY_PREFIX.len())
+ .rev()
+ .find(|len| self.pending.ends_with(&KITTY_PREFIX[..*len]))
+ .unwrap_or(0);
+ let emit = self.pending.len().saturating_sub(keep);
+ if emit > 0 {
+ output.push(self.pending.drain(..emit).collect());
+ }
+ break;
+ };
+ if start > 0 {
+ output.push(self.pending.drain(..start).collect());
+ }
+ let end = self
+ .pending
+ .windows(2)
+ .position(|window| window == KITTY_SUFFIX);
+ let Some(end) = end else {
+ if self.pending.len() > MAX_RESPONSE_BYTES {
+ output.push(std::mem::take(&mut self.pending));
+ }
+ break;
+ };
+ let command: Vec<u8> = self.pending.drain(..end + 2).collect();
+ match matcher.consume(&command) {
+ Some(Some(response)) => responses.push(response),
+ Some(None) => {}
+ None => output.push(command),
+ }
+ }
+ (output, responses)
+ }
+}
+
+fn matching_response_controls(bytes: &[u8], expected: u32) -> bool {
+ let mut matched = false;
+ for field in bytes.split(|byte| *byte == b',') {
+ let Some(separator) = field.iter().position(|byte| *byte == b'=') else {
+ return false;
+ };
+ let (key, value) = field.split_at(separator);
+ let value = &value[1..];
+ if value.is_empty() || !value.iter().all(u8::is_ascii_digit) {
+ return false;
+ }
+ match key {
+ b"i" if !matched
+ && std::str::from_utf8(value).ok().and_then(|v| v.parse().ok())
+ == Some(expected) =>
+ {
+ matched = true
+ }
+ b"I" | b"p" => {}
+ _ => return false,
+ }
+ }
+ matched
+}
+
+pub(super) fn valid_control(control: &str, image_id: u32) -> bool {
+ if control.len() > 1024 || control.contains([';', '\x1b']) {
+ return false;
+ }
+ let mut action = false;
+ let mut format = false;
+ let mut image = false;
+ let mut quiet = false;
+ let mut cursor = false;
+ for field in control.split(',') {
+ let Some((key, value)) = field.split_once('=') else {
+ return false;
+ };
+ if key == "t"
+ || !matches!(
+ key,
+ "a" | "f"
+ | "s"
+ | "v"
+ | "i"
+ | "p"
+ | "c"
+ | "r"
+ | "z"
+ | "C"
+ | "q"
+ | "x"
+ | "y"
+ | "w"
+ | "h"
+ | "X"
+ | "Y"
+ )
+ {
+ return false;
+ }
+ let numeric = value
+ .strip_prefix('-')
+ .unwrap_or(value)
+ .bytes()
+ .all(|byte| byte.is_ascii_digit())
+ && !value.is_empty();
+ if key != "a" && !numeric {
+ return false;
+ }
+ match key {
+ "a" => action = value == "T",
+ "f" => format = value == "32",
+ "i" => image = value.parse() == Ok(image_id),
+ "q" => quiet = value == "0",
+ "C" => cursor = value == "1",
+ _ => {}
+ }
+ }
+ action && format && image && quiet && cursor
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn matches_only_the_armed_image_and_preserves_unrelated_input() {
+ let mut matcher = ResponseMatcher::default();
+ let active = matcher.active_handle();
+ assert!(matcher.arm(7, 42));
+ assert!(active.load(std::sync::atomic::Ordering::Acquire));
+ assert!(!matcher.arm(8, 43));
+ assert_eq!(matcher.consume(b"typed"), None);
+ assert_eq!(matcher.consume(b"\x1b_Gi=41;OK\x1b\\"), None);
+ assert_eq!(
+ matcher.consume(b"\x1b_Gi=42;OK\x1b\\"),
+ Some(Some(Response {
+ transfer_id: 7,
+ image_id: 42,
+ success: true,
+ }))
+ );
+ assert!(!active.load(std::sync::atomic::Ordering::Acquire));
+ }
+
+ #[test]
+ fn explicit_error_is_reported_and_malformed_responses_are_ignored() {
+ let mut matcher = ResponseMatcher::default();
+ matcher.arm(9, 44);
+ assert_eq!(
+ matcher.consume(b"\x1b_Gi=44;ENOENT\x1b\\"),
+ Some(Some(Response {
+ transfer_id: 9,
+ image_id: 44,
+ success: false,
+ }))
+ );
+ matcher.arm(10, 45);
+ assert_eq!(matcher.consume(b"\x1b_Gi=45;broken"), None);
+ matcher.cancel(10);
+ matcher.arm(11, 46);
+ assert_eq!(matcher.consume(b"\x1b_Gi=46oops;OK\x1b\\"), None);
+ }
+
+ #[test]
+ fn validated_control_is_one_owned_rgba_transmit_and_display() {
+ assert!(valid_control(
+ "a=T,f=32,s=10,v=20,i=42,p=7,c=5,r=6,z=-1,C=1,q=0,x=2",
+ 42
+ ));
+ for invalid in [
+ "a=T,f=24,i=42,C=1,q=0",
+ "a=T,f=32,i=41,C=1,q=0",
+ "a=T,t=f,f=32,i=42,C=1,q=0",
+ "a=p,f=32,i=42,C=1,q=0",
+ ] {
+ assert!(!valid_control(invalid, 42), "{invalid}");
+ }
+ }
+
+ #[test]
+ fn fragmented_response_is_held_before_generic_input_timeout() {
+ let mut matcher = ResponseMatcher::default();
+ let mut filter = InputFilter::default();
+ matcher.arm(19, 49);
+ for foreign in [
+ b"\x1b_Ga=p,i=49;OK\x1b\\".as_slice(),
+ b"\x1b_Gi=49oops;OK\x1b\\",
+ ] {
+ let (output, responses) = filter.push(foreign, &mut matcher);
+ assert_eq!(output, [foreign.to_vec()]);
+ assert!(responses.is_empty());
+ }
+ let (output, responses) = filter.push(b"typed\x1b_Gi=49;", &mut matcher);
+ assert_eq!(output, [b"typed".to_vec()]);
+ assert!(responses.is_empty());
+ let (output, responses) = filter.push(b"OK\x1b\\tail", &mut matcher);
+ assert_eq!(output, [b"tail".to_vec()]);
+ assert_eq!(responses[0].transfer_id, 19);
+ assert!(responses[0].success);
+ }
+
+ #[test]
+ fn attempted_output_failure_retires_response_without_forwarding_it() {
+ let mut matcher = ResponseMatcher::default();
+ let mut filter = InputFilter::default();
+ matcher.arm(20, 50);
+ matcher.retire(20);
+ for foreign in [
+ b"\x1b_Ga=p,i=50;OK\x1b\\".as_slice(),
+ b"\x1b_Gi=50oops;OK\x1b\\",
+ ] {
+ let (output, responses) = filter.push(foreign, &mut matcher);
+ assert_eq!(output, [foreign.to_vec()]);
+ assert!(responses.is_empty());
+ }
+ assert!(filter
+ .push(b"\x1b_Gi=50;EINVAL\x1b\\", &mut matcher)
+ .0
+ .is_empty());
+ }
+
+ #[test]
+ fn timed_out_expectation_requires_a_new_transfer_before_matching() {
+ let mut matcher = ResponseMatcher::default();
+ let started = std::time::Instant::now();
+ assert!(matcher.arm(12, 47));
+ matcher.start_at(12, started);
+ assert!(!matcher.arm(13, 48));
+ matcher.expire_at(started + RESPONSE_TIMEOUT);
+ assert!(matcher.arm(13, 48));
+ assert_eq!(matcher.consume(b"typed"), None);
+ assert_eq!(matcher.consume(b"\x1b_Gi=47;OK\x1b\\"), Some(None));
+ assert_eq!(
+ matcher.consume(b"\x1b_Gi=48;OK\x1b\\"),
+ Some(Some(Response {
+ transfer_id: 13,
+ image_id: 48,
+ success: true,
+ }))
+ );
+ }
+}