use std::io::Write;
use base64::Engine;
use thiserror::Error;
use crate::redirect_stdio::StderrSuppressGuard;
#[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 _guard = StderrSuppressGuard::new();
let _ = cb.set_text(text.to_owned());
}
Ok(())
}
}
const OSC52_HEADER_LEN: usize = 5;
const OSC52_HEADER_LEN_WIN: usize = 7;
const CLIPBOARD_PARAM_LEN: usize = 2;
const MAX_OSC52_BUFFER_BYTES: usize = 4 * 1024 * 1024;
const OSC52_HDR_STD: &[u8] = b"\x1b]52;";
const OSC52_HDR_WIN: &[u8] = "←]52;".as_bytes();
fn find_osc52_header(data: &[u8]) -> Option<usize> {
if let Some(pos) = data
.windows(OSC52_HEADER_LEN)
.position(|w| w == OSC52_HDR_STD)
{
return Some(pos + OSC52_HEADER_LEN);
}
if let Some(pos) = data
.windows(OSC52_HEADER_LEN_WIN)
.position(|w| w == OSC52_HDR_WIN)
{
return Some(pos + OSC52_HEADER_LEN_WIN);
}
None
}
fn is_base64_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'='
}
pub fn extract_osc52_text(data: &[u8]) -> Option<String> {
let mut i = 0;
while i < data.len() {
let header_end = match find_osc52_header(&data[i..]) {
Some(off) => i + off,
None => {
i += 1;
continue;
}
};
let content_start = header_end;
let payload_start = if data[content_start..].starts_with(b"c;") {
content_start + CLIPBOARD_PARAM_LEN
} else {
content_start
};
let mut end = None;
let mut j = payload_start;
let mut seen_base64 = false;
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;
}
if !is_base64_char(data[j]) {
if seen_base64 {
end = Some(j);
}
break;
}
seen_base64 = true;
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
}
pub struct Osc52Extractor {
buf: Vec<u8>,
}
impl Osc52Extractor {
pub fn new() -> Self {
Self { buf: Vec::new() }
}
pub fn push(&mut self, data: &[u8], prev_tail: &[u8]) -> Option<String> {
if !self.buf.is_empty() {
self.buf.extend_from_slice(data);
return self.try_extract(data, prev_tail);
}
if let Some(header_end) = find_osc52_header(data) {
let header_len = if data.windows(OSC52_HEADER_LEN).any(|w| w == OSC52_HDR_STD) {
OSC52_HEADER_LEN
} else {
OSC52_HEADER_LEN_WIN
};
self.buf.extend_from_slice(&data[header_end - header_len..]);
return self.try_extract(data, prev_tail);
}
if !prev_tail.is_empty() {
let mut combined = prev_tail.to_vec();
combined.extend_from_slice(data);
if let Some(header_end) = find_osc52_header(&combined) {
let header_len = if combined
.windows(OSC52_HEADER_LEN)
.any(|w| w == OSC52_HDR_STD)
{
OSC52_HEADER_LEN
} else {
OSC52_HEADER_LEN_WIN
};
self.buf
.extend_from_slice(&combined[header_end - header_len..]);
return self.try_extract(data, prev_tail);
}
}
None
}
pub fn is_active(&self) -> bool {
!self.buf.is_empty()
}
pub fn clear(&mut self) {
self.buf.clear();
}
fn try_extract(&mut self, _data: &[u8], _prev_tail: &[u8]) -> Option<String> {
if self.buf.len() >= MAX_OSC52_BUFFER_BYTES {
self.buf.clear();
return None;
}
if let Some(result) = extract_osc52_text(&self.buf) {
self.buf.clear();
return Some(result);
}
None
}
}
impl Default for Osc52Extractor {
fn default() -> Self {
Self::new()
}
}
#[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()));
}
#[test]
fn extractor_single_chunk_bel() {
let seq = format_osc52_bytes("hello");
let mut ex = Osc52Extractor::new();
let result = ex.push(&seq, &[]);
assert_eq!(result.as_deref(), Some("hello"));
}
#[test]
fn extractor_multi_chunk_bel() {
let seq = format_osc52_bytes("this is a longer test");
let mid = seq.len() / 3;
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..mid], &[]).is_none());
assert!(ex.is_active());
assert!(ex.push(&seq[mid..2 * mid], &[]).is_none());
assert!(ex.is_active());
let result = ex.push(&seq[2 * mid..], &[]);
assert_eq!(result.as_deref(), Some("this is a longer test"));
assert!(!ex.is_active());
}
#[test]
fn extractor_header_cross_boundary() {
let seq = format_osc52_bytes("test");
let split = 3; assert_eq!(&seq[..split], b"\x1b]5");
assert_eq!(&seq[split..split + 3], b"2;c");
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..split], &[]).is_none());
assert!(!ex.is_active());
let result = ex.push(&seq[split..], &seq[..split]);
assert_eq!(result.as_deref(), Some("test"));
}
#[test]
fn extractor_st_terminator_cross_boundary() {
let text = "boundary test";
let encoded = base64::engine::general_purpose::STANDARD.encode(text);
let mut seq = b"\x1b]52;c;".to_vec();
seq.extend_from_slice(encoded.as_bytes());
seq.extend_from_slice(b"\x1b\\");
let split = seq.len() - 2; assert_eq!(seq[split], 0x1b);
assert_eq!(seq[split + 1], b'\\');
let mut ex = Osc52Extractor::new();
let _ = ex.push(&seq[..split], &[]); assert!(ex.is_active());
let result = ex.push(&seq[split..], &seq[split - 2..split]);
assert_eq!(result.as_deref(), Some("boundary test"));
}
#[test]
fn extractor_normal_data_no_false_positive() {
let data = b"hello\nworld\nthis is just normal text\nno osc sequences\n";
let mut ex = Osc52Extractor::new();
assert!(ex.push(data, &[]).is_none());
assert!(!ex.is_active());
}
#[test]
fn extractor_clears_on_4mb_limit() {
let mut ex = Osc52Extractor::new();
ex.buf = vec![0u8; 4 * 1024 * 1024];
assert!(ex.is_active());
assert!(ex.push(b"", &[]).is_none());
assert!(!ex.is_active());
}
}