Skip to main content

limit_cli/
clipboard_text.rs

1//! Clipboard text copy operations with multi-platform support
2//!
3//! Provides robust text-to-clipboard functionality supporting:
4//! - Native clipboard (macOS, Windows, Linux via arboard)
5//! - SSH sessions (OSC 52 escape sequences)
6//! - WSL environments (PowerShell fallback)
7
8use base64::Engine;
9use std::io::Write;
10
11/// Copy text to clipboard with automatic environment detection
12///
13/// Strategy:
14/// 1. If SSH session detected → OSC 52 escape sequence
15/// 2. Try native clipboard (arboard)
16/// 3. On Linux with WSL detected → PowerShell fallback
17#[cfg(not(target_os = "android"))]
18pub fn copy_text_to_clipboard(text: &str) -> Result<(), String> {
19    // 1. Detect SSH - use OSC 52
20    if std::env::var_os("SSH_CONNECTION").is_some() || std::env::var_os("SSH_TTY").is_some() {
21        return copy_via_osc52(text);
22    }
23
24    // 2. Try native clipboard (arboard)
25    let error = match arboard::Clipboard::new() {
26        Ok(mut clipboard) => match clipboard.set_text(text.to_string()) {
27            Ok(()) => return Ok(()),
28            Err(err) => format!("clipboard unavailable: {err}"),
29        },
30        Err(err) => format!("clipboard unavailable: {err}"),
31    };
32
33    // 3. Fallback WSL (Linux apenas)
34    #[cfg(target_os = "linux")]
35    let error = if is_probably_wsl() {
36        match copy_via_wsl_clipboard(text) {
37            Ok(()) => return Ok(()),
38            Err(wsl_err) => format!("{error}; WSL fallback failed: {wsl_err}"),
39        }
40    } else {
41        error
42    };
43
44    Err(error)
45}
46
47/// Copy text via OSC 52 escape sequence (for SSH sessions)
48#[cfg(not(target_os = "android"))]
49fn copy_via_osc52(text: &str) -> Result<(), String> {
50    let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some());
51
52    // Unix: escrever diretamente no /dev/tty
53    #[cfg(unix)]
54    {
55        use std::fs::OpenOptions;
56
57        let mut tty = OpenOptions::new()
58            .write(true)
59            .open("/dev/tty")
60            .map_err(|e| format!("failed to open /dev/tty: {e}"))?;
61        tty.write_all(sequence.as_bytes())
62            .map_err(|e| format!("failed to write OSC 52: {e}"))?;
63        tty.flush()
64            .map_err(|e| format!("failed to flush OSC 52: {e}"))?;
65    }
66
67    // Windows: usar stdout
68    #[cfg(windows)]
69    {
70        use std::io::stdout;
71        stdout()
72            .write_all(sequence.as_bytes())
73            .map_err(|e| format!("failed to write OSC 52: {e}"))?;
74        stdout()
75            .flush()
76            .map_err(|e| format!("failed to flush OSC 52: {e}"))?;
77    }
78
79    Ok(())
80}
81
82/// Generate OSC 52 escape sequence
83fn osc52_sequence(text: &str, tmux: bool) -> String {
84    let payload = base64::engine::general_purpose::STANDARD.encode(text);
85    if tmux {
86        // Tmux passthrough
87        format!("\x1bPtmux;\x1b\x1b]52;c;{payload}\x07\x1b\\")
88    } else {
89        // Standard OSC 52
90        format!("\x1b]52;c;{payload}\x07")
91    }
92}
93
94/// Copy via PowerShell (WSL fallback)
95#[cfg(all(not(target_os = "android"), target_os = "linux"))]
96fn copy_via_wsl_clipboard(text: &str) -> Result<(), String> {
97    use std::process::{Command, Stdio};
98
99    let mut child = Command::new("powershell.exe")
100        .stdin(Stdio::piped())
101        .stdout(Stdio::null())
102        .stderr(Stdio::piped())
103        .args([
104            "-NoProfile",
105            "-Command",
106            "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; \
107             $ErrorActionPreference = 'Stop'; \
108             $text = [Console]::In.ReadToEnd(); \
109             Set-Clipboard -Value $text",
110        ])
111        .spawn()
112        .map_err(|e| format!("failed to spawn powershell.exe: {e}"))?;
113
114    let Some(mut stdin) = child.stdin.take() else {
115        let _ = child.kill();
116        return Err("failed to open powershell.exe stdin".to_string());
117    };
118
119    stdin
120        .write_all(text.as_bytes())
121        .map_err(|e| format!("failed to write to powershell.exe: {e}"))?;
122
123    drop(stdin);
124
125    let output = child
126        .wait_with_output()
127        .map_err(|e| format!("failed to wait for powershell.exe: {e}"))?;
128
129    if output.status.success() {
130        Ok(())
131    } else {
132        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
133        Err(if stderr.is_empty() {
134            format!("powershell.exe exited with status {}", output.status)
135        } else {
136            format!("powershell.exe failed: {stderr}")
137        })
138    }
139}
140
141/// Detect if running under WSL (Windows Subsystem for Linux)
142#[cfg(target_os = "linux")]
143pub(crate) fn is_probably_wsl() -> bool {
144    // Verificar /proc/version para "microsoft" ou "WSL"
145    if let Ok(version) = std::fs::read_to_string("/proc/version") {
146        let version_lower = version.to_lowercase();
147        if version_lower.contains("microsoft") || version_lower.contains("wsl") {
148            return true;
149        }
150    }
151
152    // Check WSL environment variables
153    std::env::var_os("WSL_DISTRO_NAME").is_some() || std::env::var_os("WSL_INTEROP").is_some()
154}
155
156#[cfg(all(test, not(target_os = "android")))]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn osc52_sequence_encodes_text_for_terminal_clipboard() {
162        assert_eq!(osc52_sequence("hello", false), "\u{1b}]52;c;aGVsbG8=\u{7}");
163    }
164
165    #[test]
166    fn osc52_sequence_wraps_tmux_passthrough() {
167        assert_eq!(
168            osc52_sequence("hello", true),
169            "\u{1b}Ptmux;\u{1b}\u{1b}]52;c;aGVsbG8=\u{7}\u{1b}\\"
170        );
171    }
172}