diff options
| -rw-r--r-- | src/app/mod.rs | 6 | ||||
| -rw-r--r-- | src/events.rs | 6 | ||||
| -rw-r--r-- | src/pane.rs | 119 | ||||
| -rw-r--r-- | src/selection.rs | 11 |
4 files changed, 62 insertions, 80 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs index 266baa59..cefd0252 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -784,10 +784,8 @@ impl App { } fn handle_internal_event(&mut self, ev: AppEvent) { - if let AppEvent::ClipboardWrite { bytes } = ev { - use std::io::Write; - let _ = std::io::stdout().write_all(&bytes); - let _ = std::io::stdout().flush(); + if let AppEvent::ClipboardWrite { content } = ev { + crate::selection::write_osc52_bytes(&content); return; } diff --git a/src/events.rs b/src/events.rs index f43001b2..52efef61 100644 --- a/src/events.rs +++ b/src/events.rs @@ -38,7 +38,7 @@ pub enum AppEvent { }, /// A new version was downloaded and installed. Restart to use it. UpdateReady { version: String }, - /// A pane child emitted an OSC 52 clipboard-write sequence that the - /// main loop should re-emit on herdr's stdout. - ClipboardWrite { bytes: Vec<u8> }, + /// A pane child emitted a valid OSC 52 clipboard write. The main loop + /// re-emits it through herdr's own clipboard writer. + ClipboardWrite { content: Vec<u8> }, } diff --git a/src/pane.rs b/src/pane.rs index f0dc74c5..3ae7a560 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -382,12 +382,6 @@ fn is_default_color_set_osc(body: &[u8]) -> bool { /// while still bounding memory against stream garbage. const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Osc52Terminator { - Bel, - St, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum Osc52ForwarderState { #[default] @@ -398,9 +392,9 @@ enum Osc52ForwarderState { } /// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the -/// main loop can re-emit them. `libghostty-vt`'s readonly stream handler -/// drops `.clipboard_contents` actions, so without this forwarder clipboard -/// writes from child processes never reach the host terminal. +/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`, +/// so child clipboard writes never reach the host terminal unless we forward +/// them ourselves. #[derive(Debug, Default)] struct Osc52Forwarder { state: Osc52ForwarderState, @@ -429,7 +423,7 @@ impl Osc52Forwarder { } Osc52ForwarderState::OscBody => match byte { 0x07 => { - self.finalize(Osc52Terminator::Bel); + self.finalize(); self.state = Osc52ForwarderState::Ground; } 0x1b => self.state = Osc52ForwarderState::OscEscape, @@ -437,7 +431,7 @@ impl Osc52Forwarder { }, Osc52ForwarderState::OscEscape => { if byte == b'\\' { - self.finalize(Osc52Terminator::St); + self.finalize(); self.state = Osc52ForwarderState::Ground; } else { self.body.push(0x1b); @@ -454,16 +448,9 @@ impl Osc52Forwarder { } } - fn finalize(&mut self, terminator: Osc52Terminator) { - if is_osc52_clipboard_write(&self.body) { - let mut sequence = Vec::with_capacity(self.body.len() + 4); - sequence.extend_from_slice(b"\x1b]"); - sequence.extend_from_slice(&self.body); - match terminator { - Osc52Terminator::Bel => sequence.push(0x07), - Osc52Terminator::St => sequence.extend_from_slice(b"\x1b\\"), - } - self.pending.push(sequence); + fn finalize(&mut self) { + if let Some(content) = parse_osc52_clipboard_write(&self.body) { + self.pending.push(content); } self.body.clear(); } @@ -473,23 +460,20 @@ impl Osc52Forwarder { } } -/// Accepts `52;c;<base64>` and `52;;<base64>` (ghostty treats the empty -/// selector as the standard clipboard). Queries (`?`) are rejected because -/// herdr has no path to reply to the child with clipboard contents. -fn is_osc52_clipboard_write(body: &[u8]) -> bool { - let Some(rest) = body.strip_prefix(b"52;") else { - return false; - }; - let Some(sep) = rest.iter().position(|b| *b == b';') else { - return false; - }; +/// Accepts `52;c;<base64>` and `52;;<base64>`. +/// Queries (`?`) are rejected because herdr has no reply path. +/// The payload must decode as base64 before it is forwarded. +fn parse_osc52_clipboard_write(body: &[u8]) -> Option<Vec<u8>> { + use base64::Engine; + + let rest = body.strip_prefix(b"52;")?; + let sep = rest.iter().position(|b| *b == b';')?; let selector = &rest[..sep]; let data = &rest[sep + 1..]; - let is_standard_clipboard = selector.is_empty() || selector == b"c"; - if !is_standard_clipboard { - return false; + if !(selector.is_empty() || selector == b"c") || data == b"?" { + return None; } - !data.is_empty() && data != b"?" + base64::engine::general_purpose::STANDARD.decode(data).ok() } fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool { @@ -1816,6 +1800,7 @@ impl PaneRuntime { let render_dirty = render_dirty.clone(); let child_pid = child_pid.clone(); let events = events.clone(); + let rt = tokio::runtime::Handle::current(); tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8192]; loop { @@ -1836,17 +1821,14 @@ impl PaneRuntime { if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) { render_notify.notify_one(); } - for bytes in result.clipboard_writes { - // Non-blocking: stalling the PTY reader on a - // saturated main loop would be worse than - // dropping a clipboard write. + for content in result.clipboard_writes { if let Err(err) = - events.try_send(AppEvent::ClipboardWrite { bytes }) + rt.block_on(events.send(AppEvent::ClipboardWrite { content })) { warn!( pane = pane_id.raw(), err = %err, - "dropped OSC 52 clipboard write" + "failed to send OSC 52 clipboard write" ); } } @@ -2649,8 +2631,7 @@ mod tests { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]52;c;aGVsbG8=\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8=\x07"); + assert_eq!(pending, vec![b"hello".to_vec()]); } #[test] @@ -2658,8 +2639,7 @@ mod tests { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8=\x1b\\"); + assert_eq!(pending, vec![b"hello".to_vec()]); } #[test] @@ -2667,8 +2647,15 @@ mod tests { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]52;;aGVsbG8=\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;;aGVsbG8=\x07"); + assert_eq!(pending, vec![b"hello".to_vec()]); + } + + #[test] + fn osc52_forwarder_accepts_clear_clipboard() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;\x07"); + let pending = fw.drain_pending(); + assert_eq!(pending, vec![Vec::<u8>::new()]); } #[test] @@ -2697,6 +2684,14 @@ mod tests { } #[test] + fn osc52_forwarder_ignores_invalid_base64() { + let mut fw = Osc52Forwarder::default(); + fw.observe(b"\x1b]52;c;%%%\x07"); + fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07"); + assert!(fw.drain_pending().is_empty()); + } + + #[test] fn osc52_forwarder_ignores_non_osc52() { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]11;?\x07"); @@ -2714,8 +2709,7 @@ mod tests { assert!(fw.drain_pending().is_empty()); fw.observe(b"bGQ=\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGVsbG8gd29ybGQ=\x07"); + assert_eq!(pending, vec![b"hello world".to_vec()]); } #[test] @@ -2725,8 +2719,7 @@ mod tests { assert!(fw.drain_pending().is_empty()); fw.observe(b"\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07"); + assert_eq!(pending, vec![b"hi".to_vec()]); } #[test] @@ -2736,8 +2729,7 @@ mod tests { assert!(fw.drain_pending().is_empty()); fw.observe(b"\\"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGk=\x1b\\"); + assert_eq!(pending, vec![b"hi".to_vec()]); } #[test] @@ -2752,8 +2744,7 @@ mod tests { fw.observe(b"\x1b]52;c;aGk=\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07"); + assert_eq!(pending, vec![b"hi".to_vec()]); } #[test] @@ -2761,17 +2752,7 @@ mod tests { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07"); - } - - #[test] - fn osc52_forwarder_esc_in_body_not_st() { - let mut fw = Osc52Forwarder::default(); - fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07"); - let pending = fw.drain_pending(); - assert_eq!(pending.len(), 1); - assert_eq!(pending[0], b"\x1b]52;c;aGVs\x1b[bG8=\x07"); + assert_eq!(pending, vec![b"hi".to_vec()]); } #[test] @@ -2779,16 +2760,14 @@ mod tests { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07"); let pending = fw.drain_pending(); - assert_eq!(pending.len(), 2); - assert_eq!(pending[0], b"\x1b]52;c;aGk=\x07"); - assert_eq!(pending[1], b"\x1b]52;c;Ynll\x07"); + assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]); } #[test] fn osc52_forwarder_drain_clears_pending() { let mut fw = Osc52Forwarder::default(); fw.observe(b"\x1b]52;c;aGk=\x07"); - assert_eq!(fw.drain_pending().len(), 1); + assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]); assert!(fw.drain_pending().is_empty()); } diff --git a/src/selection.rs b/src/selection.rs index fab687e5..050964fd 100644 --- a/src/selection.rs +++ b/src/selection.rs @@ -165,22 +165,27 @@ fn clamp_to_pane(screen_col: u16, screen_row: u16, pane_inner: Rect) -> (u16, u1 (clamped_row - pane_inner.y, clamped_col - pane_inner.x) } -/// Write text to the system clipboard via OSC 52. +/// Write clipboard bytes to the system clipboard via OSC 52. /// /// OSC 52 format: `ESC ] 52 ; c ; <base64> BEL` /// /// The terminal emulator (Ghostty, kitty, etc.) intercepts this /// and sets the system clipboard. Ghostty has `clipboard-write = allow` /// by default. -pub fn write_osc52(text: &str) { +pub fn write_osc52_bytes(bytes: &[u8]) { use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(text); + let encoded = base64::engine::general_purpose::STANDARD.encode(bytes); // Use ST (ESC \) terminator — more widely supported than BEL in some terminals let sequence = format!("\x1b]52;c;{encoded}\x1b\\"); let _ = std::io::stdout().write_all(sequence.as_bytes()); let _ = std::io::stdout().flush(); } +/// Write text to the system clipboard via OSC 52. +pub fn write_osc52(text: &str) { + write_osc52_bytes(text.as_bytes()); +} + #[cfg(test)] mod tests { use super::*; |
