use std::borrow::Cow;
use std::io::{Write, stdout};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use base64::Engine;
use image::codecs::jpeg::JpegEncoder;
use image::{DynamicImage, RgbaImage};
use tokio::sync::mpsc::UnboundedSender;
const PNG_MAX_BYTES: u64 = 10 * 1024 * 1024;
const OUTBOX_KEEP: Duration = Duration::from_secs(7 * 24 * 60 * 60);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Copied {
System,
Terminal,
}
pub enum ClipboardEvent {
Decoded(Decoded),
Pasted(Pasted),
}
pub struct Decoded {
pub label: String,
pub image: Result<RgbaImage, String>,
}
pub struct Pasted {
pub chat_id: i64,
pub content: Result<Paste, String>,
}
pub enum Paste {
Files(Vec<PathBuf>),
Image(PathBuf),
Text(String),
}
pub struct Clipboard {
system: Option<arboard::Clipboard>,
tx: UnboundedSender<ClipboardEvent>,
outbox: PathBuf,
}
impl Clipboard {
pub fn new(tx: UnboundedSender<ClipboardEvent>, outbox: PathBuf) -> Self {
Self {
system: None,
tx,
outbox,
}
}
fn system(&mut self) -> Result<&mut arboard::Clipboard, arboard::Error> {
if self.system.is_none() {
self.system = Some(arboard::Clipboard::new()?);
}
Ok(self.system.as_mut().expect("just set"))
}
pub fn copy_text(&mut self, text: &str) -> std::io::Result<Copied> {
if let Ok(system) = self.system()
&& system.set_text(text).is_ok()
{
return Ok(Copied::System);
}
let mut out = stdout();
out.write_all(osc52(text).as_bytes())?;
out.flush()?;
Ok(Copied::Terminal)
}
pub fn copy_file(&mut self, path: &Path) -> Result<(), arboard::Error> {
self.system()?.set().file_list(&[path])
}
pub fn decode_image(&self, path: String, label: String) {
let tx = self.tx.clone();
tokio::task::spawn_blocking(move || {
let image = crate::images::open_image(&path)
.map(|i| i.to_rgba8())
.map_err(|e| e.to_string());
let _ = tx.send(ClipboardEvent::Decoded(Decoded { label, image }));
});
}
pub fn paste(&self, chat_id: i64) {
let tx = self.tx.clone();
let outbox = self.outbox.clone();
tokio::task::spawn_blocking(move || {
let content = read(&outbox);
let _ = tx.send(ClipboardEvent::Pasted(Pasted { chat_id, content }));
});
}
pub fn copy_image(&mut self, image: RgbaImage) -> Result<(), arboard::Error> {
let (width, height) = image.dimensions();
self.system()?.set_image(arboard::ImageData {
width: width as usize,
height: height as usize,
bytes: Cow::Owned(image.into_raw()),
})
}
}
fn read(outbox: &Path) -> Result<Paste, String> {
let mut clipboard =
arboard::Clipboard::new().map_err(|e| format!("Can't reach the clipboard: {e}"))?;
if let Ok(files) = clipboard.get().file_list()
&& !files.is_empty()
{
return Ok(Paste::Files(files));
}
if let Ok(image) = clipboard.get_image() {
let image = RgbaImage::from_raw(
image.width as u32,
image.height as u32,
image.bytes.into_owned(),
)
.ok_or("The image on the clipboard is broken")?;
return save_image(image, outbox)
.map(Paste::Image)
.map_err(|e| format!("Can't save the image: {e}"));
}
match clipboard.get_text() {
Ok(text) if !text.is_empty() => Ok(Paste::Text(text)),
_ => Err("Nothing to paste: the clipboard is empty".into()),
}
}
fn save_image(image: RgbaImage, outbox: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
std::fs::create_dir_all(outbox)?;
let stamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
let png = outbox.join(format!("pasted-{stamp}.png"));
image.save(&png)?;
if std::fs::metadata(&png)?.len() <= PNG_MAX_BYTES {
return Ok(png);
}
std::fs::remove_file(&png)?;
let jpeg = outbox.join(format!("pasted-{stamp}.jpg"));
let file = std::io::BufWriter::new(std::fs::File::create(&jpeg)?);
DynamicImage::ImageRgba8(image)
.to_rgb8()
.write_with_encoder(JpegEncoder::new_with_quality(file, 90))?;
Ok(jpeg)
}
pub fn clean_outbox(outbox: &Path) {
let Ok(entries) = std::fs::read_dir(outbox) else {
return;
};
for entry in entries.flatten() {
let old = entry
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > OUTBOX_KEEP);
if old {
let _ = std::fs::remove_file(entry.path());
}
}
}
fn osc52(text: &str) -> String {
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
format!("\x1b]52;c;{encoded}\x07")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_terminal_gets_the_text_in_base64() {
assert_eq!(osc52("hi ✓"), "\x1b]52;c;aGkg4pyT\x07");
}
#[test]
fn pasted_images_are_saved_as_png() {
let outbox =
std::env::temp_dir().join(format!("tuigram-test-outbox-{}", std::process::id()));
let path = save_image(RgbaImage::new(30, 20), &outbox).unwrap();
assert_eq!(path.extension().unwrap(), "png");
assert_eq!(image::image_dimensions(&path).unwrap(), (30, 20));
clean_outbox(&outbox);
assert!(path.exists(), "new ones are kept");
let _ = std::fs::remove_dir_all(&outbox);
}
}