use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use katui_present::surface::Stash;
use katui_proto::kitty::{Delete, Format, ImageId, Medium, Quiet, Transmit};
use katui_proto::probe::{Capabilities, Transport};
const PROBE_IMAGE: u32 = 29;
const PROBE_WAIT: Duration = Duration::from_millis(300);
#[derive(Debug)]
pub struct TempFileStash {
directory: PathBuf,
prefix: String,
}
static NEXT_FILE: AtomicU64 = AtomicU64::new(0);
impl TempFileStash {
#[must_use]
pub fn new() -> Self {
Self {
directory: std::env::temp_dir(),
prefix: format!("tty-graphics-protocol-katui-{}-", std::process::id()),
}
}
fn path(&self) -> PathBuf {
let number = NEXT_FILE.fetch_add(1, Ordering::Relaxed);
self.directory.join(format!("{}{number}", self.prefix))
}
}
impl Default for TempFileStash {
fn default() -> Self {
Self::new()
}
}
impl Stash for TempFileStash {
fn put(&mut self, pixels: &[u8]) -> io::Result<Medium> {
let path = self.path();
std::fs::write(&path, pixels)?;
Ok(Medium::TempFile(path.to_string_lossy().into_owned()))
}
}
#[must_use]
pub fn local_stash(caps: &Capabilities) -> Option<Box<dyn Stash>> {
caps.local_files
.then(|| Box::new(TempFileStash::new()) as Box<dyn Stash>)
}
pub(crate) fn reads_local_files(transport: &mut dyn Transport, caps: &Capabilities) -> bool {
let remote =
std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some();
if !caps.kitty || caps.multiplexed() || remote {
return false;
}
let mut stash = TempFileStash::new();
let Ok(medium) = stash.put(&[0, 0, 0]) else {
return false;
};
let answered = ask(transport, &medium);
if let Medium::TempFile(path) = medium {
let _ = std::fs::remove_file(path);
}
answered
}
fn ask(transport: &mut dyn Transport, medium: &Medium) -> bool {
let mut transmit = Transmit::new(ImageId(PROBE_IMAGE), Format::Rgb, 1, 1, &[]);
transmit.quiet = Quiet::Normal;
transmit.medium = medium.clone();
let Ok(frames) = transmit.encode() else {
return false;
};
let mut query: Vec<u8> = frames.concat();
query.extend_from_slice(&Delete::Free(ImageId(PROBE_IMAGE)).encode());
if transport.write(&query).is_err() {
return false;
}
let ok = format!("i={PROBE_IMAGE};OK");
let until = Instant::now() + PROBE_WAIT;
let mut replies = Vec::new();
while Instant::now() < until {
let Some(chunk) = transport.read_timeout(until.saturating_duration_since(Instant::now()))
else {
break;
};
replies.extend_from_slice(&chunk);
if String::from_utf8_lossy(&replies).contains(&ok) {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use std::time::Duration;
use katui_present::surface::Stash;
use katui_proto::kitty::Medium;
use katui_proto::probe::{Capabilities, Transport};
use super::{TempFileStash, reads_local_files};
struct Scripted {
replies: VecDeque<Vec<u8>>,
sent: Vec<u8>,
}
impl Transport for Scripted {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.sent.extend_from_slice(bytes);
Ok(())
}
fn read_timeout(&mut self, _wait: Duration) -> Option<Vec<u8>> {
self.replies.pop_front()
}
}
fn kitty() -> Capabilities {
Capabilities {
kitty: true,
..Capabilities::default()
}
}
#[test]
fn a_stashed_frame_lands_in_a_file_the_terminal_may_read() {
let mut stash = TempFileStash::new();
let Medium::TempFile(path) = stash.put(&[1, 2, 3, 4]).expect("the temp dir is writable")
else {
panic!("a temp file stash hands out temp files");
};
assert!(path.contains("tty-graphics-protocol"));
assert_eq!(std::fs::read(&path).expect("the file exists"), [1, 2, 3, 4]);
std::fs::remove_file(path).expect("the file is ours to remove");
}
#[test]
fn a_terminal_that_answers_ok_reads_local_files() {
let mut terminal = Scripted {
replies: VecDeque::from([b"\x1b_Gi=29;OK\x1b\\".to_vec()]),
sent: Vec::new(),
};
assert!(reads_local_files(&mut terminal, &kitty()));
assert!(String::from_utf8_lossy(&terminal.sent).contains("t=t"));
let mut silent = Scripted {
replies: VecDeque::new(),
sent: Vec::new(),
};
assert!(!reads_local_files(&mut silent, &kitty()));
assert!(!reads_local_files(&mut silent, &Capabilities::default()));
}
}