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};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ClipboardMode {
Image,
File,
}
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(())
}
pub struct ClipboardStream {
child: Option<Child>,
pub writer: Option<Box<dyn Write + Send>>,
}
impl ClipboardStream {
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 {
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()
}
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)),
})
}
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"
);
}
}