Sweep every first-party crate source (1956 .rs files) to the project comment guidelines: delete redundant restatements, decorative banners, change narration, and end-of-line comments; keep and tighten the crucial ones (invariants, bug rationale, SAFETY blocks, ported-source attribution). No functional code changed. Every edit is proven comment-only against the prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a separate doctest-fence check. Where removing a comment made rustfmt or clippy want to re-lay-out adjacent code, the minimal triggering comment is restored so code tokens stay byte-identical. Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy --workspace --all-targets (0 warnings). Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for these guidelines (flags banners, end-of-line comments, change narration, and commented-out code).
840 lines
31 KiB
Rust
840 lines
31 KiB
Rust
//! Local PTY wrapper with OSC 52 clipboard interception and host image paste.
|
|
//!
|
|
//! Spawns a command inside a local pseudo-terminal, intercepts the OSC 52
|
|
//! clipboard escape sequences it emits, and writes their payload to the local
|
|
//! system clipboard. This is the engine behind `kigi wrap` (see
|
|
//! [`crate::wrap_cmd`]); it makes clipboard "copy" work for programs running
|
|
//! somewhere that cannot reach the user's clipboard (containers, SSH) even when
|
|
//! the outer terminal does not handle OSC 52 itself (for example Apple
|
|
//! Terminal).
|
|
//!
|
|
//! Also consumes a private remote request OSC for host clipboard images and
|
|
//! injects a bracketed-paste response on PTY stdin (see
|
|
//! [`crate::wrap_clipboard_image`]). Trust model for auto-answering that OSC
|
|
//! (any PTY emitter can solicit the host pasteboard) is documented there.
|
|
|
|
use anyhow::Result;
|
|
use base64::Engine as _;
|
|
use std::io::Write;
|
|
|
|
/// Maximum size for a buffered escape sequence candidate (1 MiB).
|
|
///
|
|
/// This bounds the memory used while accumulating a candidate OSC 52 or DCS
|
|
/// sequence. Must be large enough to hold the base64-encoded form of
|
|
/// `MAX_CLIPBOARD_PAYLOAD` (~1.33x expansion) plus the escape envelope.
|
|
const MAX_ESC_BUFFER: usize = 1024 * 1024;
|
|
|
|
/// Maximum decoded clipboard payload size (768 KiB).
|
|
///
|
|
/// Aligned with `MAX_ESC_BUFFER`: a 768 KiB payload encodes to ~1 MiB of
|
|
/// base64, fitting within the buffer limit. Payloads larger than this are
|
|
/// unrealistic for clipboard content over SSH.
|
|
const MAX_CLIPBOARD_PAYLOAD: usize = 768 * 1024;
|
|
|
|
/// The prefix that identifies an OSC 52 sequence after the `ESC ]`.
|
|
const OSC52_PREFIX: &[u8] = b"52;";
|
|
|
|
/// The tmux DCS passthrough prefix after `ESC P`: `tmux;\x1b\x1b]`.
|
|
const TMUX_DCS_PREFIX: &[u8] = b"tmux;\x1b\x1b]";
|
|
|
|
/// Base64 engine that accepts both padded and unpadded input.
|
|
///
|
|
/// OSC 52 emitters in the wild (including some Go-based tools and terminals)
|
|
/// may omit `=` padding. Using `Indifferent` mode avoids silent decode
|
|
/// failures from legitimate clipboard sequences.
|
|
const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose =
|
|
base64::engine::GeneralPurpose::new(
|
|
&base64::alphabet::STANDARD,
|
|
base64::engine::GeneralPurposeConfig::new()
|
|
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
|
|
);
|
|
|
|
/// Run an arbitrary command inside a local PTY with OSC 52 output filtering.
|
|
///
|
|
/// This is the engine behind `kigi wrap`: it spawns
|
|
/// `program` (with `args`) attached to a local pseudo-terminal, forwards the
|
|
/// outer terminal's size changes to it, and filters its output through
|
|
/// [`Osc52Filter`], which intercepts OSC 52 clipboard sequences and writes
|
|
/// their payload to the local system clipboard. All other output passes
|
|
/// through unchanged.
|
|
///
|
|
/// Returns the child exit code on success.
|
|
pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result<i32> {
|
|
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
|
use std::io::Read;
|
|
|
|
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
|
|
|
let pty_system = native_pty_system();
|
|
let pair = pty_system.openpty(PtySize {
|
|
rows,
|
|
cols,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})?;
|
|
|
|
let mut cmd = CommandBuilder::new(program);
|
|
args.iter().for_each(|arg| cmd.arg(arg));
|
|
|
|
// Advertise to the wrapped program — and anything it spawns, e.g. a remote
|
|
// `kigi` reached over SSH — that its OSC 52 clipboard writes are being
|
|
// intercepted here and copied to the real local clipboard. The inner kigi
|
|
// reads this (see `kigi_pager_render::clipboard::osc52_sink_active`) to
|
|
// *trust* OSC 52 even when it can't detect an OSC-52-capable terminal,
|
|
// which is the usual SSH case (only `TERM` propagates, so Apple Terminal /
|
|
// unknown brands look incapable and the inner kigi would otherwise report
|
|
// "Copy failed" despite the copy actually working).
|
|
//
|
|
// `CommandBuilder::new` inherits the full parent environment; `env` overlays
|
|
// these two without clearing it. The canonical `KIGI_OSC52_SINK` is
|
|
// inherited by local children; the `LC_`-prefixed alias rides the default
|
|
// OpenSSH env forwarding (`SendEnv LANG LC_*` on the client,
|
|
// `AcceptEnv LANG LC_*` on the server) so the signal survives the SSH hop.
|
|
cmd.env("KIGI_OSC52_SINK", "1");
|
|
cmd.env("LC_KIGI_OSC52_SINK", "1");
|
|
|
|
let mut child = pair.slave.spawn_command(cmd)?;
|
|
// Drop the slave so we get EOF when child exits.
|
|
drop(pair.slave);
|
|
|
|
// Obtain reader from the master PTY. All master *writes* (keystrokes +
|
|
// host-image inject frames) go through one dedicated writer thread via a
|
|
// channel. Cross-thread use of portable-pty's `Write` impl observed EIO
|
|
// (errno 5) on macOS even for small inject payloads; confining `write_all`
|
|
// to a single owner thread avoids that. Handles are intentionally
|
|
// detached — `kigi wrap` is short-lived and exits with the child.
|
|
let mut pty_reader = pair.master.try_clone_reader()?;
|
|
|
|
// NOTE: we intentionally do NOT block SIGWINCH here. The resize handler
|
|
// (`sigwinch_loop`) installs a real signal handler via `signal-hook`,
|
|
// which must be free to run when the signal is delivered. Blocking it and
|
|
// waiting via `sigwait` looks correct but silently fails on macOS (see
|
|
// `sigwinch_loop`).
|
|
|
|
// Switch to raw mode so keystrokes pass through unchanged.
|
|
crossterm::terminal::enable_raw_mode()?;
|
|
let _raw_guard = RawModeGuard;
|
|
|
|
let (write_tx, write_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
|
{
|
|
let mut writer = pair.master.take_writer()?;
|
|
std::thread::spawn(move || {
|
|
while let Ok(bytes) = write_rx.recv() {
|
|
// Single-thread write_all: no libc chunking. EIO here almost
|
|
// always means the slave has closed (child exited); stop.
|
|
if writer
|
|
.write_all(&bytes)
|
|
.and_then(|_| writer.flush())
|
|
.is_err()
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
let stdin_tx = write_tx.clone();
|
|
let _stdin_handle = std::thread::spawn(move || {
|
|
let mut stdin = std::io::stdin().lock();
|
|
let mut buf = [0u8; 4096];
|
|
loop {
|
|
match stdin.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
if stdin_tx.send(buf[..n].to_vec()).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// We move `pair.master` here since we've already cloned the reader and
|
|
// taken the writer above.
|
|
//
|
|
// Unix only: there is no SIGWINCH on Windows. On Windows `pair.master` is
|
|
// instead kept alive inside `pair` until this function returns (after
|
|
// `child.wait`), so the ConPTY stays open for the read loop — the OSC 52
|
|
// clipboard bridge works identically, only live resize is unavailable.
|
|
#[cfg(unix)]
|
|
{
|
|
let master = pair.master;
|
|
std::thread::spawn(move || {
|
|
sigwinch_loop(master);
|
|
});
|
|
}
|
|
|
|
// Host-image requests: spawn a short-lived worker for clipboard I/O (can be
|
|
// hundreds of ms / osascript) then enqueue the bracketed-paste frame on the
|
|
// writer thread (+ NL so ICANON slaves deliver without another key). Paste
|
|
// mashing can spawn multiple workers; fine for a short-lived wrap process.
|
|
{
|
|
let mut stdout = std::io::stdout().lock();
|
|
let mut filter = Osc52Filter::new().with_wrap_image_handler(move || {
|
|
let tx = write_tx.clone();
|
|
std::thread::spawn(move || {
|
|
let mut bytes = host_clipboard_image_frame();
|
|
bytes.push(b'\n');
|
|
let _ = tx.send(bytes);
|
|
});
|
|
});
|
|
let mut buf = [0u8; 8192];
|
|
loop {
|
|
match pty_reader.read(&mut buf) {
|
|
Ok(0) | Err(_) => break,
|
|
Ok(n) => {
|
|
let filtered = filter.feed(&buf[..n]);
|
|
if !filtered.is_empty() {
|
|
if stdout.write_all(&filtered).is_err() {
|
|
break;
|
|
}
|
|
let _ = stdout.flush();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let status = child.wait()?;
|
|
let code = status.exit_code() as i32;
|
|
|
|
Ok(code)
|
|
}
|
|
|
|
/// Wait for `SIGWINCH` and resize the PTY master to match the outer terminal.
|
|
///
|
|
/// Uses `signal-hook` to install a real signal handler (self-pipe based).
|
|
///
|
|
/// A previous implementation blocked SIGWINCH and dequeued it with `nix`'s
|
|
/// `sigwait`. That pattern is POSIX-correct but **silently fails on macOS**:
|
|
/// SIGWINCH's default disposition is "ignore", and macOS discards a blocked
|
|
/// default-ignore signal rather than leaving it pending for `sigwait`. The
|
|
/// handler never woke, so the inner PTY was never resized and the remote TUI
|
|
/// kept rendering at the original size — producing overlapping/stale frames
|
|
/// after the user resized their terminal. Installing an actual handler
|
|
/// overrides the default-ignore disposition, which is what makes it work.
|
|
#[cfg(unix)]
|
|
fn sigwinch_loop(master: Box<dyn portable_pty::MasterPty + Send>) {
|
|
use portable_pty::PtySize;
|
|
|
|
let mut signals = match signal_hook::iterator::Signals::new([signal_hook::consts::SIGWINCH]) {
|
|
Ok(signals) => signals,
|
|
Err(e) => {
|
|
tracing::debug!("failed to install SIGWINCH handler: {e}");
|
|
return;
|
|
}
|
|
};
|
|
|
|
for _ in signals.forever() {
|
|
if let Ok((cols, rows)) = crossterm::terminal::size() {
|
|
let _ = master.resize(PtySize {
|
|
rows,
|
|
cols,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Guard that restores terminal state when dropped (including on panic).
|
|
struct RawModeGuard;
|
|
|
|
impl Drop for RawModeGuard {
|
|
fn drop(&mut self) {
|
|
let _ = crossterm::terminal::disable_raw_mode();
|
|
}
|
|
}
|
|
|
|
/// State machine states for the OSC 52 streaming parser.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum FilterState {
|
|
/// Normal output passthrough.
|
|
Normal,
|
|
/// Saw ESC (0x1b), waiting for next byte to determine sequence type.
|
|
Esc,
|
|
/// Inside OSC: saw `ESC ]` -- accumulating until BEL or ST.
|
|
Osc,
|
|
/// Inside DCS: saw `ESC P` -- checking for tmux passthrough prefix.
|
|
Dcs,
|
|
/// Inside DCS tmux passthrough, accumulating inner OSC 52.
|
|
DcsTmuxOsc,
|
|
/// Saw ESC inside an OSC, could be ST terminator (`ESC \`).
|
|
OscEsc,
|
|
/// Saw ESC inside a DCS tmux OSC, could be inner ST or DCS ST.
|
|
DcsTmuxOscEsc,
|
|
}
|
|
|
|
/// Clipboard sink type: a boxed closure that receives decoded clipboard data.
|
|
type ClipboardSink = Box<dyn FnMut(&[u8])>;
|
|
|
|
type WrapImageRequestHandler = Box<dyn FnMut()>;
|
|
|
|
/// Streaming filter that intercepts OSC 52 clipboard sequences from PTY
|
|
/// output and sends their decoded payload to the local clipboard.
|
|
///
|
|
/// All non-OSC-52 bytes pass through unchanged. The parser handles sequences
|
|
/// split across arbitrary byte boundaries.
|
|
struct Osc52Filter {
|
|
state: FilterState,
|
|
buf: Vec<u8>,
|
|
clipboard_sink: ClipboardSink,
|
|
wrap_image_handler: Option<WrapImageRequestHandler>,
|
|
}
|
|
|
|
impl Osc52Filter {
|
|
/// Create a new filter that sends clipboard data to the system clipboard.
|
|
fn new() -> Self {
|
|
Self {
|
|
state: FilterState::Normal,
|
|
buf: Vec::new(),
|
|
clipboard_sink: Box::new(set_local_clipboard),
|
|
wrap_image_handler: None,
|
|
}
|
|
}
|
|
|
|
fn with_wrap_image_handler(mut self, handler: impl FnMut() + 'static) -> Self {
|
|
self.wrap_image_handler = Some(Box::new(handler));
|
|
self
|
|
}
|
|
|
|
/// Create a filter with a custom clipboard sink (for testing).
|
|
#[cfg(test)]
|
|
fn with_sink(sink: impl FnMut(&[u8]) + 'static) -> Self {
|
|
Self {
|
|
state: FilterState::Normal,
|
|
buf: Vec::new(),
|
|
clipboard_sink: Box::new(sink),
|
|
wrap_image_handler: None,
|
|
}
|
|
}
|
|
|
|
/// Process a chunk of bytes from PTY output.
|
|
///
|
|
/// Returns bytes that should be written to stdout. OSC 52 clipboard
|
|
/// sequences are consumed (not included in the output) and their decoded
|
|
/// payload is sent to the clipboard sink.
|
|
fn feed(&mut self, data: &[u8]) -> Vec<u8> {
|
|
let mut output = Vec::with_capacity(data.len());
|
|
for &byte in data {
|
|
match self.state {
|
|
FilterState::Normal => {
|
|
if byte == 0x1b {
|
|
self.state = FilterState::Esc;
|
|
self.buf.clear();
|
|
self.buf.push(byte);
|
|
} else {
|
|
output.push(byte);
|
|
}
|
|
}
|
|
FilterState::Esc => {
|
|
self.buf.push(byte);
|
|
match byte {
|
|
b']' => self.state = FilterState::Osc,
|
|
b'P' => self.state = FilterState::Dcs,
|
|
_ => {
|
|
// Not an OSC or DCS -- flush buffer and continue.
|
|
output.extend_from_slice(&self.buf);
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
}
|
|
}
|
|
}
|
|
FilterState::Osc => {
|
|
self.buf.push(byte);
|
|
match byte {
|
|
// BEL terminates the OSC sequence.
|
|
0x07 => {
|
|
if !self.try_handle_consumed_osc() {
|
|
output.extend_from_slice(&self.buf);
|
|
}
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
}
|
|
// ESC could be the start of ST (ESC \).
|
|
0x1b => {
|
|
self.state = FilterState::OscEsc;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
FilterState::OscEsc => {
|
|
self.buf.push(byte);
|
|
if byte == b'\\' {
|
|
// ST terminator: ESC \.
|
|
if !self.try_handle_consumed_osc() {
|
|
output.extend_from_slice(&self.buf);
|
|
}
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
} else {
|
|
// Not ST -- continue accumulating in Osc state.
|
|
// The ESC we saw might be part of the payload in some
|
|
// broken sequence; just keep buffering.
|
|
self.state = FilterState::Osc;
|
|
}
|
|
}
|
|
FilterState::Dcs => {
|
|
self.buf.push(byte);
|
|
// buf starts with \x1bP so tmux prefix bytes start at offset 2.
|
|
let prefix_pos = self.buf.len() - 2;
|
|
if prefix_pos <= TMUX_DCS_PREFIX.len() {
|
|
if TMUX_DCS_PREFIX[prefix_pos - 1] == byte {
|
|
if prefix_pos == TMUX_DCS_PREFIX.len() {
|
|
// Full tmux prefix matched: \x1bPtmux;\x1b\x1b]
|
|
self.state = FilterState::DcsTmuxOsc;
|
|
}
|
|
// else keep matching prefix
|
|
} else {
|
|
// Prefix mismatch: not a tmux passthrough, flush.
|
|
output.extend_from_slice(&self.buf);
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
}
|
|
} else {
|
|
// Exceeded prefix length without matching; flush.
|
|
output.extend_from_slice(&self.buf);
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
}
|
|
}
|
|
FilterState::DcsTmuxOsc => {
|
|
self.buf.push(byte);
|
|
match byte {
|
|
// BEL terminates the inner OSC.
|
|
0x07 => {
|
|
// Inner OSC is done but we still need DCS ST
|
|
// (ESC \) to close the tmux wrapper.
|
|
// Remain in this state to catch the ESC.
|
|
}
|
|
0x1b => {
|
|
self.state = FilterState::DcsTmuxOscEsc;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
FilterState::DcsTmuxOscEsc => {
|
|
self.buf.push(byte);
|
|
if byte == b'\\' {
|
|
// DCS ST: ESC \. The full tmux-wrapped sequence is done.
|
|
if !self.try_handle_tmux_osc52() {
|
|
output.extend_from_slice(&self.buf);
|
|
}
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
} else {
|
|
// Not ST. Continue accumulating in DcsTmuxOsc.
|
|
self.state = FilterState::DcsTmuxOsc;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Guard: if the buffer grows beyond the limit, flush and reset.
|
|
if self.buf.len() > MAX_ESC_BUFFER {
|
|
output.extend_from_slice(&self.buf);
|
|
self.buf.clear();
|
|
self.state = FilterState::Normal;
|
|
}
|
|
}
|
|
output
|
|
}
|
|
|
|
/// Handle OSC 52 clipboard or wrap image request; `true` if consumed.
|
|
fn try_handle_consumed_osc(&mut self) -> bool {
|
|
let body = self.buf[2..].to_vec();
|
|
let body = strip_osc_terminator(&body);
|
|
if self.try_handle_wrap_image_request(body) {
|
|
return true;
|
|
}
|
|
self.extract_and_set_clipboard(body)
|
|
}
|
|
|
|
fn try_handle_wrap_image_request(&mut self, body: &[u8]) -> bool {
|
|
if body != crate::wrap_clipboard_image::REQUEST_BODY {
|
|
return false;
|
|
}
|
|
if let Some(handler) = self.wrap_image_handler.as_mut() {
|
|
handler();
|
|
}
|
|
true
|
|
}
|
|
|
|
/// Try to handle the buffered bytes as a tmux-wrapped OSC 52 sequence.
|
|
///
|
|
/// Expected buffer format:
|
|
/// `\x1bPtmux;\x1b\x1b]52;<sel>;<base64>\x07\x1b\\`
|
|
///
|
|
/// Returns `true` if the sequence was a valid OSC 52 and was consumed.
|
|
fn try_handle_tmux_osc52(&mut self) -> bool {
|
|
// Strip the DCS tmux prefix: \x1bPtmux;\x1b\x1b] (total 9 bytes)
|
|
// and the DCS ST terminator: \x1b\ (2 bytes at the end).
|
|
// Copy the body to avoid borrowing self.buf while calling &mut self.
|
|
let prefix_len = 2 + TMUX_DCS_PREFIX.len();
|
|
if self.buf.len() < prefix_len + 2 {
|
|
return false;
|
|
}
|
|
let body = self.buf[prefix_len..self.buf.len() - 2].to_vec();
|
|
// Strip inner BEL if present.
|
|
let body = strip_osc_terminator(&body);
|
|
self.extract_and_set_clipboard(body)
|
|
}
|
|
|
|
/// Parse OSC 52 body (`52;<sel>;<base64>`), decode, and set clipboard.
|
|
///
|
|
/// Returns `true` if successfully handled.
|
|
fn extract_and_set_clipboard(&mut self, body: &[u8]) -> bool {
|
|
if !body.starts_with(OSC52_PREFIX) {
|
|
return false;
|
|
}
|
|
let after_52 = &body[OSC52_PREFIX.len()..];
|
|
|
|
// Find the selection parameter separator (next ';').
|
|
let payload_start = match after_52.iter().position(|&b| b == b';') {
|
|
Some(pos) => pos + 1,
|
|
None => return false,
|
|
};
|
|
let b64_payload = &after_52[payload_start..];
|
|
|
|
let decoded = match BASE64_STANDARD_INDIFFERENT.decode(b64_payload) {
|
|
Ok(data) => data,
|
|
Err(_) => return false,
|
|
};
|
|
|
|
if decoded.len() > MAX_CLIPBOARD_PAYLOAD {
|
|
tracing::warn!(
|
|
"OSC 52 payload too large ({} bytes), ignoring",
|
|
decoded.len()
|
|
);
|
|
return false;
|
|
}
|
|
|
|
(self.clipboard_sink)(&decoded);
|
|
true
|
|
}
|
|
}
|
|
|
|
/// Strip the OSC terminator from the end of a body slice.
|
|
///
|
|
/// Removes trailing BEL (`\x07`) or ST (`\x1b\x5c`) if present.
|
|
fn strip_osc_terminator(body: &[u8]) -> &[u8] {
|
|
if body.ends_with(&[0x1b, b'\\']) {
|
|
&body[..body.len() - 2]
|
|
} else if body.ends_with(&[0x07]) {
|
|
&body[..body.len() - 1]
|
|
} else {
|
|
body
|
|
}
|
|
}
|
|
|
|
/// Write decoded clipboard payload to the local system clipboard.
|
|
///
|
|
/// Delegates to [`kigi_shell::util::clipboard::set_text`] which uses
|
|
/// `pbcopy` on macOS and `arboard` elsewhere. Failures are logged but do
|
|
/// not propagate -- clipboard access is best-effort.
|
|
fn set_local_clipboard(data: &[u8]) {
|
|
let text = match std::str::from_utf8(data) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
tracing::warn!("OSC 52 payload is not valid UTF-8: {e}");
|
|
return;
|
|
}
|
|
};
|
|
if let Err(e) = kigi_shell::util::clipboard::set_text(text) {
|
|
tracing::warn!("clipboard copy failed: {e}");
|
|
}
|
|
}
|
|
|
|
/// Encode a host clipboard image (or NONE) as a bracketed-paste frame.
|
|
fn host_clipboard_image_frame() -> Vec<u8> {
|
|
let image = kigi_pager_render::clipboard::system_clipboard_get_image();
|
|
crate::wrap_clipboard_image::encode_wrap_image_response(image.as_ref())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
/// Helper: run data through the filter with a capturing clipboard sink.
|
|
/// Returns (stdout_output, captured_clipboard_payloads).
|
|
fn filter_output(input: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) {
|
|
let clips = Rc::new(RefCell::new(Vec::new()));
|
|
let clips_clone = Rc::clone(&clips);
|
|
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
|
clips_clone.borrow_mut().push(data.to_vec());
|
|
});
|
|
let output = filter.feed(input);
|
|
let captured = clips.borrow().clone();
|
|
(output, captured)
|
|
}
|
|
|
|
/// Helper: run data through the filter in multiple small chunks.
|
|
fn filter_output_chunked(input: &[u8], chunk_size: usize) -> (Vec<u8>, Vec<Vec<u8>>) {
|
|
let clips = Rc::new(RefCell::new(Vec::new()));
|
|
let clips_clone = Rc::clone(&clips);
|
|
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
|
clips_clone.borrow_mut().push(data.to_vec());
|
|
});
|
|
let mut output = Vec::new();
|
|
for chunk in input.chunks(chunk_size) {
|
|
output.extend_from_slice(&filter.feed(chunk));
|
|
}
|
|
let captured = clips.borrow().clone();
|
|
(output, captured)
|
|
}
|
|
|
|
/// Encode text as a plain OSC 52 sequence with BEL terminator.
|
|
fn make_osc52_bel(text: &str) -> Vec<u8> {
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
|
format!("\x1b]52;c;{b64}\x07").into_bytes()
|
|
}
|
|
|
|
/// Encode text as a plain OSC 52 sequence with ST terminator.
|
|
fn make_osc52_st(text: &str) -> Vec<u8> {
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
|
format!("\x1b]52;c;{b64}\x1b\\").into_bytes()
|
|
}
|
|
|
|
/// Encode text as a tmux-wrapped OSC 52 sequence.
|
|
fn make_osc52_tmux(text: &str) -> Vec<u8> {
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
|
format!("\x1bPtmux;\x1b\x1b]52;c;{b64}\x07\x1b\\").into_bytes()
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_normal_text_unchanged() {
|
|
let input = b"Hello, world!\r\n";
|
|
let (output, clips) = filter_output(input);
|
|
assert_eq!(output, input);
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_ansi_escapes_pass_through() {
|
|
// SGR color: ESC [ 31 m
|
|
let input = b"\x1b[31mred text\x1b[0m";
|
|
let (output, clips) = filter_output(input);
|
|
assert_eq!(output, input.as_slice());
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_plain_bel_terminated() {
|
|
let seq = make_osc52_bel("hello");
|
|
let (output, clips) = filter_output(&seq);
|
|
assert!(
|
|
output.is_empty(),
|
|
"OSC 52 should be consumed, got: {output:?}"
|
|
);
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_plain_st_terminated() {
|
|
let seq = make_osc52_st("hello");
|
|
let (output, clips) = filter_output(&seq);
|
|
assert!(
|
|
output.is_empty(),
|
|
"OSC 52 should be consumed, got: {output:?}"
|
|
);
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"hello");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_with_s0_selection() {
|
|
// Selection parameter "s0" instead of "c".
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(b"clipboard data");
|
|
let seq = format!("\x1b]52;s0;{b64}\x07").into_bytes();
|
|
let (output, clips) = filter_output(&seq);
|
|
assert!(output.is_empty());
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"clipboard data");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_tmux_wrapped() {
|
|
let seq = make_osc52_tmux("hello from tmux");
|
|
let (output, clips) = filter_output(&seq);
|
|
assert!(
|
|
output.is_empty(),
|
|
"tmux OSC 52 should be consumed, got: {output:?}"
|
|
);
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"hello from tmux");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_surrounded_by_text() {
|
|
let mut input = b"before ".to_vec();
|
|
input.extend_from_slice(&make_osc52_bel("copied"));
|
|
input.extend_from_slice(b" after");
|
|
let (output, clips) = filter_output(&input);
|
|
assert_eq!(output, b"before after");
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"copied");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_multiple_sequences() {
|
|
let mut input = make_osc52_bel("first");
|
|
input.extend_from_slice(b"gap");
|
|
input.extend_from_slice(&make_osc52_st("second"));
|
|
let (output, clips) = filter_output(&input);
|
|
assert_eq!(output, b"gap");
|
|
assert_eq!(clips.len(), 2);
|
|
assert_eq!(clips[0], b"first");
|
|
assert_eq!(clips[1], b"second");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_split_across_chunks() {
|
|
let seq = make_osc52_bel("split test");
|
|
// Feed one byte at a time.
|
|
let (output, clips) = filter_output_chunked(&seq, 1);
|
|
assert!(output.is_empty(), "should be consumed even byte-by-byte");
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"split test");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_split_at_various_sizes() {
|
|
let seq = make_osc52_st("chunk test");
|
|
for chunk_size in 2..=seq.len() {
|
|
let (output, clips) = filter_output_chunked(&seq, chunk_size);
|
|
assert!(
|
|
output.is_empty(),
|
|
"chunk_size={chunk_size}: should be consumed"
|
|
);
|
|
assert_eq!(clips.len(), 1, "chunk_size={chunk_size}: expected 1 clip");
|
|
assert_eq!(clips[0], b"chunk test");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_tmux_split_across_chunks() {
|
|
let seq = make_osc52_tmux("tmux split");
|
|
let (output, clips) = filter_output_chunked(&seq, 3);
|
|
assert!(output.is_empty());
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"tmux split");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_invalid_base64_passes_through() {
|
|
// Invalid base64 payload: "!!!" is not valid base64.
|
|
let seq = b"\x1b]52;c;!!!\x07";
|
|
let (output, clips) = filter_output(seq);
|
|
assert_eq!(output, seq.as_slice(), "invalid base64 should pass through");
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_non_52_osc_passes_through() {
|
|
// OSC 0 (window title) should pass through.
|
|
let seq = b"\x1b]0;my title\x07";
|
|
let (output, clips) = filter_output(seq);
|
|
assert_eq!(output, seq.as_slice());
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_non_52_osc_st_passes_through() {
|
|
// OSC 0 with ST terminator.
|
|
let seq = b"\x1b]0;my title\x1b\\";
|
|
let (output, clips) = filter_output(seq);
|
|
assert_eq!(output, seq.as_slice());
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_oversized_buffer_flushes() {
|
|
// Build a sequence that exceeds MAX_ESC_BUFFER.
|
|
let mut seq = b"\x1b]52;c;".to_vec();
|
|
// Fill with valid base64 chars until we exceed the limit.
|
|
seq.resize(MAX_ESC_BUFFER + 100, b'A');
|
|
seq.push(0x07);
|
|
|
|
let (output, clips) = filter_output(&seq);
|
|
assert!(
|
|
!output.is_empty(),
|
|
"oversized sequence should flush through"
|
|
);
|
|
assert!(
|
|
clips.is_empty(),
|
|
"oversized sequence should not set clipboard"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_empty_payload() {
|
|
// Empty base64 payload should still work (copies empty string).
|
|
let seq = b"\x1b]52;c;\x07";
|
|
let (output, clips) = filter_output(seq);
|
|
assert!(output.is_empty());
|
|
assert_eq!(clips.len(), 1);
|
|
assert_eq!(clips[0], b"");
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_non_tmux_dcs_passes_through() {
|
|
// A DCS that doesn't start with the tmux prefix should flush.
|
|
let seq = b"\x1bPother;stuff\x1b\\";
|
|
let (output, clips) = filter_output(seq);
|
|
// The flush happens when the prefix mismatch is detected.
|
|
assert!(!output.is_empty(), "non-tmux DCS should pass through");
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn osc52_missing_selection_separator() {
|
|
// No second ';' after "52;" -- missing selection param separator.
|
|
let b64 = base64::engine::general_purpose::STANDARD.encode(b"data");
|
|
let seq = format!("\x1b]52;{b64}\x07").into_bytes();
|
|
// This has "52;" followed by base64 with no second ';'. The parser
|
|
// will treat everything after "52;" up to the next ';' as the
|
|
// selection param. If there's no ';', it returns false.
|
|
let (output, clips) = filter_output(&seq);
|
|
assert_eq!(output, seq, "should pass through without second ';'");
|
|
assert!(clips.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_image_request_consumed_and_handler_runs() {
|
|
let calls = Rc::new(RefCell::new(0usize));
|
|
let calls_clone = Rc::clone(&calls);
|
|
let clips = Rc::new(RefCell::new(Vec::new()));
|
|
let clips_clone = Rc::clone(&clips);
|
|
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
|
clips_clone.borrow_mut().push(data.to_vec());
|
|
})
|
|
.with_wrap_image_handler(move || {
|
|
*calls_clone.borrow_mut() += 1;
|
|
});
|
|
let mut input = b"before".to_vec();
|
|
input.extend_from_slice(&crate::wrap_clipboard_image::request_osc_bytes());
|
|
input.extend_from_slice(b"after");
|
|
let output = filter.feed(&input);
|
|
assert_eq!(output, b"beforeafter");
|
|
assert_eq!(*calls.borrow(), 1);
|
|
assert!(clips.borrow().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn wrap_image_request_split_across_chunks() {
|
|
let calls = Rc::new(RefCell::new(0usize));
|
|
let calls_clone = Rc::clone(&calls);
|
|
let mut filter = Osc52Filter::with_sink(|_| {}).with_wrap_image_handler(move || {
|
|
*calls_clone.borrow_mut() += 1;
|
|
});
|
|
let seq = crate::wrap_clipboard_image::request_osc_bytes();
|
|
let mut output = Vec::new();
|
|
for chunk in seq.chunks(3) {
|
|
output.extend_from_slice(&filter.feed(chunk));
|
|
}
|
|
assert!(output.is_empty(), "request OSC must be fully consumed");
|
|
assert_eq!(*calls.borrow(), 1);
|
|
}
|
|
}
|