wsl-clip-core 0.5.1

Core library for wsl-clip clipboard bridge
Documentation
// <FILE>src/clipboard.rs</FILE> - <DESC>Clipboard interop helpers for Windows host</DESC>
// <VERS>VERSION: 2.2.0 - 2025-12-08T00:00:00Z</VERS>
// <WCTX>Documented public API for external consumers; behavior unchanged.</WCTX>
// <CLOG>Added doc comments for library users; kept PowerShell transport intact.</CLOG>

use crate::debug_logger::create_logger;
use anyhow::{Context, Result};
use base64::engine::general_purpose::STANDARD;
use base64::write::EncoderWriter;
use std::io::Write;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
/// Target clipboard mode when sending files/images to Windows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardMode {
    /// Copy a single image file as a bitmap.
    Image,
    /// Copy one or more files as a Windows file drop list.
    File,
}
/// Copy files or a single image to the Windows clipboard via PowerShell.
///
/// - `win_paths` must be Windows-style absolute paths (use `paths::to_windows_path`).
/// - `Image` mode requires exactly one path.
pub fn set_complex(win_paths: &[String], mode: ClipboardMode) -> Result<()> {
    if mock_clipboard_enabled() {
        let body = win_paths
            .iter()
            .enumerate()
            .map(|(i, p)| format!("{}{}|{:?}", if i > 0 { "\n" } else { "" }, p, mode as u8))
            .collect::<String>();
        record_mock(&body);
        return Ok(());
    }
    let log = create_logger("clipboard");
    if let ClipboardMode::Image = mode {
        if win_paths.len() != 1 {
            anyhow::bail!("Image mode currently supports exactly one file at a time.");
        }
    }
    let header =
        "Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing;";
    let body = match mode {
        ClipboardMode::Image => {
            "$img = [System.Drawing.Image]::FromFile($args[0]); [System.Windows.Forms.Clipboard]::SetImage($img);"
        }
        ClipboardMode::File => {
            "$files = New-Object System.Collections.Specialized.StringCollection; $args | ForEach-Object { [void]$files.Add($_) }; [System.Windows.Forms.Clipboard]::SetFileDropList($files);"
        }
    };
    let script = format!("{} & {{ {} }}", header, body);
    log.debug("Executing PowerShell clipboard script (Parameterized)...");
    let status = Command::new("powershell.exe")
        .arg("-NoProfile")
        .arg("-Command")
        .arg(&script)
        .args(win_paths)
        .status()
        .with_context(|| "Failed to execute powershell.exe")?;
    if !status.success() {
        log.error("PowerShell exited with error status");
        anyhow::bail!("PowerShell exited with error status");
    }
    Ok(())
}
/// Streaming handle returned by `start_text_stream` for large text payloads.
pub struct ClipboardStream {
    child: Option<Child>,
    pub writer: Option<Box<dyn Write + Send>>,
}
impl ClipboardStream {
    /// Flushes the writer, waits for PowerShell to exit, and surfaces errors.
    pub fn wait(mut self) -> Result<()> {
        if let Some(mut w) = self.writer.take() {
            w.flush().context("Failed to flush writer")?;
            drop(w);
        }
        if let Some(mut child) = self.child {
            let status = child
                .wait()
                .context("Failed to wait for clipboard process")?;
            if !status.success() {
                anyhow::bail!("Clipboard process exited with error status");
            }
        }
        Ok(())
    }
}

fn mock_clipboard_enabled() -> bool {
    std::env::var_os("WSL_CLIP_TEST_CLIPBOARD_FILE").is_some()
}

static MOCK_CLIPBOARD: OnceLock<Arc<Mutex<String>>> = OnceLock::new();

fn mock_clipboard_sink() -> Option<Arc<Mutex<String>>> {
    if mock_clipboard_enabled() {
        Some(MOCK_CLIPBOARD.get_or_init(|| Arc::new(Mutex::new(String::new()))).clone())
    } else {
        None
    }
}

fn record_mock(content: &str) {
    if let Some(sink) = mock_clipboard_sink() {
        *sink.lock().unwrap() = content.to_string();
    }
}

struct MockClipboardWriter {
    sink: Arc<Mutex<String>>,
}

impl Write for MockClipboardWriter {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        let mut guard = self.sink.lock().unwrap();
        guard.push_str(&String::from_utf8_lossy(buf));
        Ok(buf.len())
    }
    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}
fn get_text_stream_script() -> String {
    // PowerShell Script:
    // 1. Load Windows Forms (Critical for Clipboard access)
    // 2. Read all Stdin (Base64 string)
    // 3. Remove whitespace (newlines)
    // 4. Decode Base64 -> Bytes
    // 5. Decode Bytes -> UTF8 String
    // 6. Set Clipboard
    r#"
    Add-Type -AssemblyName System.Windows.Forms;
    $b64 = [Console]::In.ReadToEnd();
    if ($b64) {
        $clean = $b64 -replace '\s','';
        $bytes = [System.Convert]::FromBase64String($clean);
        $utf8 = [System.Text.Encoding]::UTF8.GetString($bytes);
        [System.Windows.Forms.Clipboard]::SetText($utf8);
    }
    "#
    .to_string()
}
/// Spawn PowerShell in streaming mode; returns a writer you can feed UTF-8 bytes.
pub fn start_text_stream() -> Result<ClipboardStream> {
    if mock_clipboard_enabled() {
        let sink = mock_clipboard_sink().unwrap();
        let writer = MockClipboardWriter { sink };
        return Ok(ClipboardStream {
            child: None,
            writer: Some(Box::new(writer)),
        });
    }
    let log = create_logger("clipboard");
    log.debug("Spawning PowerShell with Base64 decoder...");
    let cmd = get_text_stream_script();
    let mut child = Command::new("powershell.exe")
        .arg("-NoProfile")
        .arg("-Command")
        .arg(cmd)
        .stdin(Stdio::piped())
        .spawn()
        .with_context(|| "Failed to spawn powershell.exe")?;
    let stdin = child.stdin.take().context("Failed to open stdin")?;
    let encoder = EncoderWriter::new(stdin, &STANDARD);
    Ok(ClipboardStream {
        child: Some(child),
        writer: Some(Box::new(encoder)),
    })
}
/// Convenience helper to send a single UTF-8 string to the clipboard.
pub fn set_text_content(content: &str) -> Result<()> {
    use std::io::Write;
    if mock_clipboard_enabled() {
        record_mock(content);
        return Ok(());
    }
    let mut stream = start_text_stream()?;
    if let Some(writer) = &mut stream.writer {
        writer.write_all(content.as_bytes())?;
    }
    stream.wait()
}

pub fn read_mock_clipboard() -> Option<String> {
    mock_clipboard_sink().map(|s| s.lock().unwrap().clone())
}
#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn test_script_imports_forms() {
        let script = get_text_stream_script();
        assert!(
            script.contains("Add-Type -AssemblyName System.Windows.Forms"),
            "Script must load Windows Forms assembly to avoid TypeNotFound errors"
        );
    }
    #[test]
    fn test_script_decodes_base64() {
        let script = get_text_stream_script();
        assert!(
            script.contains("FromBase64String"),
            "Script must handle Base64 decoding"
        );
        assert!(
            script.contains("UTF8.GetString"),
            "Script must force UTF-8 decoding"
        );
    }
}

// <FILE>src/clipboard.rs</FILE> - <DESC>Added regression test for PowerShell script</DESC>
// <VERS>END OF VERSION: 2.1.0 - 2025-12-04T14:53:54Z</VERS>