use std::io::Write;
use base64::Engine;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ClipboardError {
#[error("clipboard backend error: {0}")]
Backend(#[from] arboard::Error),
#[error("I/O error writing OSC 52 sequence: {0}")]
Io(#[from] std::io::Error),
#[error("clipboard backend not available (running remotely?)")]
NotAvailable,
}
pub fn format_osc52_bytes(text: &str) -> Vec<u8> {
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
format!("\x1b]52;c;{encoded}\x07").into_bytes()
}
pub fn set_via_osc52_with_writer(text: &str, writer: &mut dyn Write) -> Result<(), ClipboardError> {
let seq = format_osc52_bytes(text);
writer.write_all(&seq)?;
writer.flush()?;
Ok(())
}
pub struct Clipboard {
arboard: Option<arboard::Clipboard>,
#[cfg(test)]
pub osc52_output: Vec<u8>,
}
impl Default for Clipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard {
pub fn new() -> Self {
Self {
arboard: arboard::Clipboard::new().ok(),
#[cfg(test)]
osc52_output: Vec::new(),
}
}
pub fn get(&mut self) -> Result<String, ClipboardError> {
self.arboard
.as_mut()
.ok_or(ClipboardError::NotAvailable)?
.get_text()
.map_err(ClipboardError::from)
}
pub fn set(&mut self, text: &str) -> Result<(), ClipboardError> {
#[cfg(not(test))]
let _ = set_via_osc52_with_writer(text, &mut std::io::stdout().lock());
#[cfg(test)]
{
let mut buf = Vec::new();
let _ = set_via_osc52_with_writer(text, &mut buf);
self.osc52_output = buf;
}
if let Some(cb) = &mut self.arboard {
let _ = cb.set_text(text.to_owned());
}
Ok(())
}
}
pub fn extract_osc52_text(data: &[u8]) -> Option<String> {
let mut i = 0;
while i < data.len() {
if i + 5 > data.len() || data[i] != 0x1b || data[i + 1] != b']' {
i += 1;
continue;
}
let header = b"52;";
if i + 5 + header.len() > data.len() {
break;
}
if &data[i + 2..i + 2 + header.len()] != header {
i += 1;
continue;
}
let content_start = i + 2 + header.len(); let payload_start = if data[content_start..].starts_with(b"c;") {
content_start + 2
} else {
content_start
};
let mut end = None;
let mut j = payload_start;
while j < data.len() {
if data[j] == 0x07 {
end = Some(j);
break;
}
if data[j] == 0x1b && j + 1 < data.len() && data[j + 1] == b'\\' {
end = Some(j);
break;
}
j += 1;
}
if let Some(end_pos) = end {
let b64 = &data[payload_start..end_pos];
if let Ok(decoded) =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
&& let Ok(text) = String::from_utf8(decoded)
{
return Some(text);
}
return None;
}
break;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clipboard_set_emits_osc52() {
let mut cb = Clipboard::new();
cb.set("hello from test").unwrap();
assert!(
!cb.osc52_output.is_empty(),
"OSC 52 output must not be empty"
);
let seq = String::from_utf8_lossy(&cb.osc52_output);
assert!(
seq.starts_with("\x1b]52;c;"),
"OSC 52 must start with correct header, got: {seq:?}"
);
assert!(
seq.ends_with('\x07'),
"OSC 52 must end with BEL, got: {seq:?}"
);
assert_eq!(
extract_osc52_text(&cb.osc52_output),
Some("hello from test".to_string()),
"OSC 52 output must survive extract roundtrip"
);
}
#[test]
fn extract_osc52_bel_terminated() {
let data = b"before\x1b]52;c;aGVsbG8=\x07after";
assert_eq!(extract_osc52_text(data), Some("hello".to_string()));
}
#[test]
fn extract_osc52_st_terminated() {
let data = b"\x1b]52;c;d29ybGQ=\x1b\\trailing";
assert_eq!(extract_osc52_text(data), Some("world".to_string()));
}
#[test]
fn extract_osc52_no_pc_param() {
let data = b"\x1b]52;dGVzdA==\x07";
assert_eq!(extract_osc52_text(data), Some("test".to_string()));
}
#[test]
fn extract_osc52_empty_data() {
assert_eq!(extract_osc52_text(b""), None);
assert_eq!(extract_osc52_text(b"no osc here"), None);
}
#[test]
fn extract_osc52_malformed_base64() {
let data = b"\x1b]52;c;!!!\x07";
assert_eq!(extract_osc52_text(data), None);
}
#[test]
fn osc52_roundtrip_ascii() {
let input = "hello world";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_empty() {
let input = "";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_unicode() {
let input = "héllo 日本語 ✅";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_roundtrip_newlines() {
let input = "line1\nline2\r\nline3";
let bytes = format_osc52_bytes(input);
assert_eq!(extract_osc52_text(&bytes), Some(input.to_string()));
}
#[test]
fn osc52_format_matches_expected_wire_format() {
let bytes = format_osc52_bytes("hello");
let expected = b"\x1b]52;c;aGVsbG8=\x07";
assert_eq!(bytes.as_slice(), expected);
}
#[test]
fn osc52_formatted_embedded_in_larger_buffer_still_extracts() {
let mut buf = b"some normal output\n".to_vec();
buf.extend_from_slice(&format_osc52_bytes("secret"));
buf.extend_from_slice(b"\nmore output");
assert_eq!(extract_osc52_text(&buf), Some("secret".to_string()));
}
#[test]
fn osc52_multiple_sequences_extracts_first() {
let bytes1 = format_osc52_bytes("first");
let bytes2 = format_osc52_bytes("second");
let mut combined = bytes1.clone();
combined.extend_from_slice(&bytes2);
assert_eq!(extract_osc52_text(&combined), Some("first".to_string()));
}
#[test]
fn osc52_set_via_osc52_writer_does_not_panic() {
let mut buf = Vec::new();
let _ = set_via_osc52_with_writer("test", &mut buf);
}
#[test]
fn set_via_osc52_with_writer_writes_correct_bytes() {
let mut buf = Vec::new();
set_via_osc52_with_writer("hello world", &mut buf).unwrap();
let expected = format_osc52_bytes("hello world");
assert_eq!(
buf, expected,
"writer should contain exactly the OSC 52 sequence"
);
}
#[test]
fn set_via_osc52_with_writer_roundtrips_through_extract() {
let mut buf = Vec::new();
set_via_osc52_with_writer("hello 日本語", &mut buf).unwrap();
assert_eq!(
extract_osc52_text(&buf),
Some("hello 日本語".to_string()),
"writer output should survive extract roundtrip"
);
}
#[test]
fn clipboard_set_triggers_osc52_path() {
let mut buf = Vec::new();
set_via_osc52_with_writer("clip test", &mut buf).unwrap();
let seq = String::from_utf8_lossy(&buf);
assert!(
seq.starts_with("\x1b]52;c;"),
"should start with OSC 52 header"
);
assert!(seq.ends_with('\x07'), "should end with BEL terminator");
assert_eq!(extract_osc52_text(&buf), Some("clip test".to_string()));
}
}