use std::io::Write;
use std::sync::{Arc, OnceLock, RwLock};
use base64::Engine;
use thiserror::Error;
use crate::redirect_stdio::StderrSuppressGuard;
pub const DEFAULT_MAX_OSC52_BYTES: usize = 1024 * 1024;
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();
#[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,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ClipboardConfig {
pub osc52_enabled: bool,
pub osc52_limit: usize,
}
impl Default for ClipboardConfig {
fn default() -> Self {
Self {
osc52_enabled: true,
osc52_limit: DEFAULT_MAX_OSC52_BYTES,
}
}
}
fn default_shared_buffer() -> Arc<RwLock<Option<String>>> {
static BUF: OnceLock<Arc<RwLock<Option<String>>>> = OnceLock::new();
BUF.get_or_init(|| Arc::new(RwLock::new(None))).clone()
}
pub struct Clipboard {
arboard: Option<arboard::Clipboard>,
shared: Arc<RwLock<Option<String>>>,
osc52_enabled: bool,
osc52_limit: usize,
#[cfg(test)]
pub osc52_output: Vec<u8>,
}
impl Default for Clipboard {
fn default() -> Self {
Self::new()
}
}
impl Clipboard {
pub fn new() -> Self {
Self::with_config(ClipboardConfig::default())
}
pub fn with_shared_buffer(buffer: Arc<RwLock<Option<String>>>) -> Self {
Self {
arboard: None,
shared: buffer,
osc52_enabled: ClipboardConfig::default().osc52_enabled,
osc52_limit: ClipboardConfig::default().osc52_limit,
#[cfg(test)]
osc52_output: Vec::new(),
}
}
pub fn with_config(config: ClipboardConfig) -> Self {
let arboard = arboard::Clipboard::new().ok();
tracing::debug!(
"clipboard: backend arboard={}, osc52 enabled={}",
if arboard.is_some() {
"available"
} else {
"unavailable"
},
config.osc52_enabled
);
Self {
arboard,
shared: default_shared_buffer(),
osc52_enabled: config.osc52_enabled,
osc52_limit: config.osc52_limit,
#[cfg(test)]
osc52_output: Vec::new(),
}
}
pub fn get(&mut self) -> Result<String, ClipboardError> {
match read_system_clipboard(&mut self.arboard) {
Ok(Some(text)) => return Ok(text),
Ok(None) => {}
Err(e) => {
tracing::debug!(
"clipboard: get via arboard failed ({e}); falling back to in-memory buffer"
);
}
}
let text = self
.shared
.read()
.unwrap_or_else(|e| e.into_inner())
.clone();
match text {
Some(text) => {
tracing::debug!(
"clipboard: get read via in-memory shared buffer ({} bytes)",
text.len()
);
Ok(text)
}
None => {
tracing::debug!(
"clipboard: get no backend available (arboard absent, buffer empty)"
);
Err(ClipboardError::NotAvailable)
}
}
}
pub fn set(&mut self, text: &str) {
if let Ok(mut guard) = self.shared.write() {
*guard = Some(text.to_owned());
}
write_system_clipboard(&mut self.arboard, text);
if !self.osc52_enabled {
tracing::debug!("clipboard: set OSC 52 emission disabled; skipping");
return;
}
let osc52_text = truncate_for_osc52(text, self.osc52_limit);
#[cfg(not(test))]
if let Err(e) = set_via_osc52_with_writer(osc52_text, &mut std::io::stdout().lock()) {
tracing::debug!("clipboard: set OSC 52 to stdout failed ({e})");
}
#[cfg(test)]
{
let mut buf = Vec::new();
let _ = set_via_osc52_with_writer(osc52_text, &mut buf);
self.osc52_output = buf;
}
}
}
fn write_system_clipboard(clipboard: &mut Option<arboard::Clipboard>, text: &str) {
let Some(cb) = clipboard.as_mut() else {
tracing::debug!("clipboard: set arboard unavailable; in-memory buffer + OSC 52 only");
return;
};
let _guard = StderrSuppressGuard::new();
match cb.set_text(text.to_owned()) {
Ok(()) => tracing::debug!("clipboard: set wrote via arboard"),
Err(e) => tracing::debug!("clipboard: set via arboard failed ({e})"),
}
}
fn read_system_clipboard(
clipboard: &mut Option<arboard::Clipboard>,
) -> Result<Option<String>, ClipboardError> {
let Some(cb) = clipboard.as_mut() else {
return Ok(None);
};
match cb.get_text() {
Ok(text) => {
tracing::debug!("clipboard: get read via arboard ({} bytes)", text.len());
Ok(Some(text))
}
Err(e) => {
tracing::debug!("clipboard: get via arboard failed ({e})");
Err(e.into())
}
}
}
fn truncate_for_osc52(text: &str, limit: usize) -> &str {
&text[..text.floor_char_boundary(limit)]
}
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(())
}
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::*;
fn isolated_buffer() -> Arc<RwLock<Option<String>>> {
Arc::new(RwLock::new(None))
}
fn headless_with_buffer(buffer: Arc<RwLock<Option<String>>>) -> Clipboard {
Clipboard::with_shared_buffer(buffer)
}
#[test]
fn in_memory_roundtrip_via_set_get_when_arboard_absent() {
let buffer = isolated_buffer();
let mut cb = headless_with_buffer(Arc::clone(&buffer));
cb.set("clipboard text");
assert_eq!(
*buffer.read().unwrap(),
Some("clipboard text".to_owned()),
"set() must write the shared in-memory buffer"
);
assert_eq!(cb.get().unwrap(), "clipboard text");
assert_eq!(cb.get().unwrap(), "clipboard text");
}
#[test]
fn shared_buffer_shared_across_instances() {
let buffer = isolated_buffer();
let mut writer = headless_with_buffer(Arc::clone(&buffer));
let mut reader = headless_with_buffer(Arc::clone(&buffer));
writer.set("shared text");
assert_eq!(
reader.get().unwrap(),
"shared text",
"a set() on one handle must be readable by another handle"
);
}
#[test]
fn in_memory_unicode_roundtrip() {
let mut cb = headless_with_buffer(isolated_buffer());
cb.set("héllo 日本語 ✅");
assert_eq!(cb.get().unwrap(), "héllo 日本語 ✅");
}
#[test]
fn get_missing_returns_not_available() {
let mut cb = headless_with_buffer(isolated_buffer());
assert!(matches!(cb.get(), Err(ClipboardError::NotAvailable)));
}
#[test]
fn get_recovers_from_poisoned_lock() {
let buffer = isolated_buffer();
let mut cb = headless_with_buffer(Arc::clone(&buffer));
cb.set("prior value");
let poisoner = Arc::clone(&buffer);
let t = std::thread::spawn(move || {
let _guard = poisoner.write().unwrap();
panic!("intentional panic while holding clipboard lock");
});
let _ = t.join();
assert_eq!(
cb.get().unwrap(),
"prior value",
"get() must recover from a poisoned lock via into_inner"
);
}
#[test]
fn clipboard_error_display_messages() {
assert_eq!(
ClipboardError::NotAvailable.to_string(),
"clipboard backend not available (running remotely?)"
);
let io = ClipboardError::Io(std::io::Error::other("boom"));
assert_eq!(io.to_string(), "I/O error writing OSC 52 sequence: boom");
let backend = ClipboardError::Backend(arboard::Error::ContentNotAvailable);
assert_eq!(
backend.to_string(),
"clipboard backend error: The clipboard contents were not available in the requested format or the clipboard is empty."
);
}
#[test]
fn clipboard_error_from_conversions() {
let io: ClipboardError = std::io::Error::other("x").into();
assert!(matches!(io, ClipboardError::Io(_)));
let arboard_err: ClipboardError = arboard::Error::ContentNotAvailable.into();
assert!(matches!(arboard_err, ClipboardError::Backend(_)));
}
#[test]
fn clipboard_default_config() {
let config = ClipboardConfig::default();
assert!(config.osc52_enabled);
assert_eq!(config.osc52_limit, DEFAULT_MAX_OSC52_BYTES);
let cb = Clipboard::new();
assert!(cb.osc52_enabled);
assert_eq!(cb.osc52_limit, DEFAULT_MAX_OSC52_BYTES);
}
#[test]
fn set_always_writes_memory_buffer_even_when_osc52_disabled() {
let buffer = isolated_buffer();
let mut cb = Clipboard::with_config(ClipboardConfig {
osc52_enabled: false,
..ClipboardConfig::default()
});
cb.arboard = None;
cb.shared = Arc::clone(&buffer);
cb.set("gated text");
assert_eq!(
*buffer.read().unwrap(),
Some("gated text".to_owned()),
"in-memory buffer must be written regardless of OSC 52 gating"
);
assert!(
cb.osc52_output.is_empty(),
"OSC 52 must not be emitted when osc52_enabled is false"
);
}
#[test]
fn clipboard_set_emits_osc52() {
let mut cb = headless_with_buffer(isolated_buffer());
cb.set("hello from test");
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 osc52_emission_truncated_over_limit() {
let buffer = isolated_buffer();
let mut cb = Clipboard::with_config(ClipboardConfig {
osc52_enabled: true,
osc52_limit: 8,
});
cb.arboard = None;
cb.shared = Arc::clone(&buffer);
let oversized = "this text is longer than the 8-byte cap";
cb.set(oversized);
let decoded = extract_osc52_text(&cb.osc52_output).unwrap();
assert!(
decoded.len() <= 8,
"OSC 52 emission must be truncated to the cap, got {} bytes",
decoded.len()
);
assert!(
oversized.starts_with(&decoded),
"truncated emission must be a prefix of the original text"
);
assert_eq!(
*buffer.read().unwrap(),
Some(oversized.to_owned()),
"in-memory buffer must keep the full text"
);
}
#[test]
fn osc52_truncation_respects_utf8_boundary() {
let mut cb = Clipboard::with_config(ClipboardConfig {
osc52_enabled: true,
osc52_limit: 5,
});
cb.arboard = None;
let text = "héllo";
cb.set(text);
let decoded = extract_osc52_text(&cb.osc52_output).unwrap();
assert_eq!(decoded, "héll", "must truncate at a valid UTF-8 boundary");
assert!(decoded.len() <= 5);
assert!(!cb.osc52_output.is_empty(), "OSC 52 must still be emitted");
}
#[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());
}
#[test]
fn extractor_new_starts_inactive() {
let ex = Osc52Extractor::new();
assert!(!ex.is_active());
}
#[test]
fn extractor_push_empty_data_no_activation() {
let mut ex = Osc52Extractor::new();
assert!(ex.push(b"", &[]).is_none());
assert!(!ex.is_active());
}
#[test]
fn extractor_clear_resets_in_progress() {
let seq = format_osc52_bytes("hello");
let mid = seq.len() / 2;
let mut ex = Osc52Extractor::new();
assert!(ex.push(&seq[..mid], &[]).is_none());
assert!(ex.is_active());
ex.clear();
assert!(!ex.is_active());
let result = ex.push(&seq, &[]);
assert_eq!(result.as_deref(), Some("hello"));
}
}