diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 511808d..e9d1509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,16 @@ jobs: artifact_name: target/aarch64-apple-darwin/release/richclip archive_type: tar.gz + ## Windows builds + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: target/x86_64-pc-windows-msvc/release/richclip.exe + archive_type: zip + - os: windows-latest + target: aarch64-pc-windows-msvc + artifact_name: target/aarch64-pc-windows-msvc/release/richclip.exe + archive_type: zip + steps: - uses: actions/checkout@v4 with: @@ -45,6 +55,7 @@ jobs: strip: true - name: Archive Release + if: runner.os != 'Windows' uses: TheDoctor0/zip-release@0.7.6 with: type: ${{ matrix.archive_type }} @@ -52,6 +63,13 @@ jobs: path: richclip filename: richclip_${{ github.ref_name }}_${{ matrix.target }}.${{ matrix.archive_type }} + - name: Archive Windows Release + if: runner.os == 'Windows' + shell: pwsh + run: | + $archive = "target/${{ matrix.target }}/release/richclip_${{ github.ref_name }}_${{ matrix.target }}.zip" + Compress-Archive -Path "${{ matrix.artifact_name }}" -DestinationPath $archive + - name: Upload artifacts uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1ce43c5..61a7ee4 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -25,6 +25,9 @@ jobs: ## macOS builds - os: macos-latest + ## Windows builds + - os: windows-latest + steps: - uses: actions/checkout@v4 - name: Install Linux test dependencies @@ -33,6 +36,7 @@ jobs: sudo apt-get update sudo apt-get install -y xvfb xclip - name: Setup Bats and bats libs + if: runner.os != 'Windows' id: setup-bats uses: bats-core/bats-action@3.0.1 - name: Build @@ -58,3 +62,15 @@ jobs: env: BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }} TERM: xterm + + - name: Run integration tests on Windows + if: runner.os == 'Windows' + run: test/windows/windows.ps1 + shell: pwsh + + - name: Check Windows ARM64 build + if: runner.os == 'Windows' + run: | + rustup target add aarch64-pc-windows-msvc + cargo build --locked --target aarch64-pc-windows-msvc + shell: pwsh diff --git a/CHANGES.md b/CHANGES.md index a4eb224..aaa244b 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ Next Release +- Add Windows x86-64 and ARM64 support. + v0.3.0 - Add Mac support. diff --git a/Cargo.lock b/Cargo.lock index cf18fa7..63b76a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -176,6 +176,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cocoa" version = "0.26.1" @@ -356,6 +365,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -775,9 +790,9 @@ version = "0.3.0" dependencies = [ "anyhow", "clap", + "clipboard-win", "cocoa", "daemonize", - "libc", "log", "nix", "objc", diff --git a/Cargo.toml b/Cargo.toml index 7783ac8..6852f2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,21 +8,22 @@ build = "build.rs" [dependencies] anyhow = "1.0.98" clap = { version = "4.5.38", features = ["derive"] } -daemonize = "0.5.0" log = "0.4.27" -libc = "0.2.172" simplelog = "0.12.2" -objc = "0.2.7" [target.'cfg(target_os = "linux")'.dependencies] +daemonize = "0.5.0" wayrs-client = { version = "1.3.1" } wayrs-protocols = { version = "0.14.9", features = ["wlr-data-control-unstable-v1"] } x11rb = { version = "0.13.1", features = [] } nix = "0.29.0" [target.'cfg(target_os = "macos")'.dependencies] -x11rb = { version = "0.13.1", features = [] } cocoa = { version = "0.26.0" } +objc = "0.2.7" + +[target.'cfg(target_os = "windows")'.dependencies] +clipboard-win = { version = "5.4.1", features = ["std"] } [build-dependencies] vergen-git2 = { version = "9.1.0", features = ["build", "cargo"] } diff --git a/README.md b/README.md index 59480e6..d3323ba 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,7 @@ command line utility as an alternative to [xclip](https://github.com/astrand/xcl [xsel](https://github.com/astrand/xclip), [wl-clipboard](https://github.com/bugaevc/wl-clipboard) or other command line clipboard tools. -- Supports Multiple environments. Xorg, Wayland and macOS are supported by the current - version. Windows is planned. +- Supports multiple environments. Xorg, Wayland, macOS, and Windows are supported. - Recognizes the environment automatically, and choose the right clipboard to use. - Supports multiple content with different mime-types simultaneously. A typical @@ -22,13 +21,14 @@ or other command line clipboard tools. Install `richclip` from the [AUR](https://aur.archlinux.org/packages/richclip). -### macOS & Other Linux Distributions +### macOS, Windows & Other Linux Distributions Download the static linked binary from the [release page](https://github.com/beeender/richclip/releases). ### Windows -Not supported yet +Release archives are provided for x86-64 and ARM64 Windows. Extract `richclip.exe` +and place it in a directory included in `PATH`. ## Usage diff --git a/src/clipboard/mod.rs b/src/clipboard/mod.rs index e30ce68..26c534a 100644 --- a/src/clipboard/mod.rs +++ b/src/clipboard/mod.rs @@ -1,8 +1,12 @@ #[cfg(target_os = "macos")] mod mac; +#[cfg(any(target_os = "linux", target_os = "windows"))] mod mime_type; #[cfg(target_os = "linux")] mod wayland; +#[cfg(target_os = "windows")] +mod windows; +#[cfg(target_os = "linux")] mod x; use super::protocol::SourceData; @@ -19,20 +23,25 @@ pub trait ClipBackend { pub struct PasteConfig { // Only list mime-types pub list_types_only: bool, + #[cfg(target_os = "linux")] pub use_primary: bool, pub expected_mime_type: String, pub writer: Box, } pub struct CopyConfig { + #[cfg(target_os = "linux")] pub use_primary: bool, pub source_data: Box, // For testing X INCR mode + #[cfg(target_os = "linux")] pub x_chunk_size: usize, } #[cfg(target_os = "macos")] use mac::MacBackend; +#[cfg(target_os = "windows")] +use windows::WindowsBackend; #[cfg(target_os = "linux")] pub use wayland::WaylandBackend; @@ -68,3 +77,8 @@ pub fn create_backend() -> Result> { Ok(Box::new(MacBackend {})) } + +#[cfg(target_os = "windows")] +pub fn create_backend() -> Result> { + Ok(Box::new(WindowsBackend {})) +} diff --git a/src/clipboard/windows.rs b/src/clipboard/windows.rs new file mode 100644 index 0000000..3a5230a --- /dev/null +++ b/src/clipboard/windows.rs @@ -0,0 +1,220 @@ +use super::mime_type::decide_mime_type; +use super::{ClipBackend, CopyConfig, PasteConfig}; +use anyhow::{Context, Result, anyhow}; +use clipboard_win::formats::{self, Html}; +use clipboard_win::options::NoClear; +use clipboard_win::raw; +use clipboard_win::{Clipboard, Getter}; +use std::collections::HashSet; +use std::io::Write; + +pub struct WindowsBackend {} + +impl ClipBackend for WindowsBackend { + fn copy(&self, config: CopyConfig) -> Result<()> { + copy_windows(config) + } + + fn paste(&self, config: PasteConfig) -> Result<()> { + paste_windows(config) + } +} + +fn copy_windows(config: CopyConfig) -> Result<()> { + let _clipboard = open_clipboard()?; + raw::empty().map_err(|err| anyhow!("Failed to empty the clipboard: {err}"))?; + + let mut written_formats = HashSet::new(); + for mime_type in config.source_data.mime_types() { + let (found, content) = config.source_data.content_by_mime_type(&mime_type); + if !found { + continue; + } + + let format = format_for_mime_type(&mime_type)?; + if !written_formats.insert(format) { + continue; + } + + if format == formats::CF_UNICODETEXT { + let text = std::str::from_utf8(&content) + .with_context(|| format!("Clipboard text for '{mime_type}' is not valid UTF-8"))?; + raw::set_string_with::(text, NoClear) + .map_err(|err| anyhow!("Failed to set Windows Unicode text: {err}"))?; + } else if is_html_type(&mime_type) { + let html = std::str::from_utf8(&content) + .with_context(|| format!("Clipboard HTML for '{mime_type}' is not valid UTF-8"))?; + raw::set_html(format, html) + .map_err(|err| anyhow!("Failed to set Windows HTML: {err}"))?; + } else { + raw::set_without_clear(format, &content) + .map_err(|err| anyhow!("Failed to set clipboard format '{mime_type}': {err}"))?; + } + } + + Ok(()) +} + +fn paste_windows(mut config: PasteConfig) -> Result<()> { + let _clipboard = open_clipboard()?; + let formats = available_formats(); + + if config.list_types_only { + for (mime_type, _) in formats { + writeln!(config.writer, "{mime_type}")?; + } + return Ok(()); + } + + let supported: Vec = formats + .iter() + .map(|(mime_type, _)| mime_type.clone()) + .collect(); + let selected = decide_mime_type(&config.expected_mime_type, &supported).with_context(|| { + format!( + "Content for mime-type '{}' doesn't exist", + config.expected_mime_type + ) + })?; + let format = formats + .iter() + .find_map(|(mime_type, format)| { + mime_type.eq_ignore_ascii_case(&selected).then_some(*format) + }) + .context("Selected clipboard format disappeared")?; + + if format == formats::CF_UNICODETEXT { + let mut text = String::new(); + clipboard_win::formats::Unicode + .read_clipboard(&mut text) + .map_err(|err| anyhow!("Failed to read Windows Unicode text: {err}"))?; + config.writer.write_all(text.as_bytes())?; + } else if is_html_format(format) { + let html = Html::new().context("Failed to register the Windows HTML clipboard format")?; + let mut content = Vec::new(); + html.read_clipboard(&mut content) + .map_err(|err| anyhow!("Failed to read Windows HTML: {err}"))?; + config.writer.write_all(&content)?; + } else { + let mut content = Vec::new(); + raw::get_vec(format, &mut content) + .map_err(|err| anyhow!("Failed to read clipboard format '{selected}': {err}"))?; + config.writer.write_all(&content)?; + } + config.writer.flush()?; + + Ok(()) +} + +fn open_clipboard() -> Result { + Clipboard::new_attempts(10) + .map_err(|err| anyhow!("Failed to open the Windows clipboard: {err}")) +} + +fn available_formats() -> Vec<(String, u32)> { + let mut formats = Vec::new(); + let mut seen = HashSet::new(); + + for format in raw::EnumFormats::new() { + let Some(mime_type) = mime_type_for_format(format) else { + continue; + }; + if seen.insert(mime_type.to_ascii_lowercase()) { + formats.push((mime_type, format)); + } + } + + formats +} + +fn format_for_mime_type(mime_type: &str) -> Result { + if is_text_type(mime_type) { + return Ok(formats::CF_UNICODETEXT); + } + + let format_name = if is_html_type(mime_type) { + "HTML Format" + } else if is_rtf_type(mime_type) { + "Rich Text Format" + } else { + mime_type + }; + + raw::register_format(format_name) + .map(|format| format.get()) + .ok_or_else(|| anyhow!("Failed to register clipboard format '{format_name}'")) +} + +fn mime_type_for_format(format: u32) -> Option { + if format == formats::CF_UNICODETEXT { + return Some("text/plain;charset=utf-8".to_string()); + } + + let format_name = raw::format_name_big(format)?; + match format_name.as_str() { + "HTML Format" => Some("text/html".to_string()), + "Rich Text Format" => Some("application/rtf".to_string()), + name if name.starts_with("CF_") => None, + _ => Some(format_name), + } +} + +fn is_html_format(format: u32) -> bool { + Html::new().is_some_and(|html| html.code() == format) +} + +fn is_text_type(mime_type: &str) -> bool { + matches!( + mime_type.to_ascii_lowercase().as_str(), + "text/plain" | "text/plain;charset=utf-8" | "text" | "string" | "utf8_string" + ) +} + +fn is_html_type(mime_type: &str) -> bool { + matches!( + mime_type.to_ascii_lowercase().as_str(), + "text/html" | "text/html;charset=utf-8" | "html" + ) +} + +fn is_rtf_type(mime_type: &str) -> bool { + matches!( + mime_type.to_ascii_lowercase().as_str(), + "application/rtf" | "text/rtf" | "rtf" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_common_mime_types() { + assert_eq!( + format_for_mime_type("text/plain").unwrap(), + formats::CF_UNICODETEXT + ); + assert_eq!( + raw::format_name_big(format_for_mime_type("text/html").unwrap()).unwrap(), + "HTML Format" + ); + assert_eq!( + raw::format_name_big(format_for_mime_type("application/rtf").unwrap()).unwrap(), + "Rich Text Format" + ); + assert_eq!( + raw::format_name_big(format_for_mime_type("application/x-richclip-test").unwrap()) + .unwrap(), + "application/x-richclip-test" + ); + } + + #[test] + fn ignores_unmapped_standard_formats() { + assert_eq!( + mime_type_for_format(formats::CF_UNICODETEXT).unwrap(), + "text/plain;charset=utf-8" + ); + assert!(mime_type_for_format(formats::CF_BITMAP).is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 7c606c3..b081fc5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ extern crate clap; +#[cfg(target_os = "linux")] extern crate daemonize; extern crate log; extern crate simplelog; @@ -42,6 +43,7 @@ struct CopyArgs { value_name = "mime-type", default_missing_value = "TEXT", action = ArgAction::Append )] mime_types: Option>, /// For testing X INCR mode + #[cfg(target_os = "linux")] #[arg( long = "chunk-size", hide = true, @@ -181,8 +183,7 @@ fn do_copy(copy_args: &CopyArgs) -> Result<()> { source_data: Box::new(source_data), #[cfg(target_os = "linux")] use_primary: copy_args.primary, - #[cfg(not(target_os = "linux"))] - use_primary: false, + #[cfg(target_os = "linux")] x_chunk_size: copy_args.chunk_size, }; clipboard::create_backend()? @@ -195,8 +196,6 @@ fn do_paste(paste_args: &PasteArgs) -> Result<()> { list_types_only: paste_args.list_types, #[cfg(target_os = "linux")] use_primary: paste_args.primary, - #[cfg(not(target_os = "linux"))] - use_primary: false, writer: Box::new(stdout()), expected_mime_type: paste_args.type_.clone(), }; diff --git a/test/windows/windows.ps1 b/test/windows/windows.ps1 new file mode 100644 index 0000000..477f44f --- /dev/null +++ b/test/windows/windows.ps1 @@ -0,0 +1,51 @@ +$ErrorActionPreference = "Stop" + +$root = Resolve-Path "$PSScriptRoot\..\.." +$richclip = Join-Path $root "target\debug\richclip.exe" +$cargo = (Get-Command cargo -ErrorAction SilentlyContinue).Source +if (-not $cargo) { + $cargo = Join-Path $HOME ".cargo\bin\cargo.exe" +} + +function Assert-Equal { + param( + [string]$Expected, + [string]$Actual, + [string]$Message + ) + + if ($Expected -cne $Actual) { + throw "$Message`nExpected: '$Expected'`nActual: '$Actual'" + } +} + +& $cargo build --quiet +if ($LASTEXITCODE -ne 0) { + throw "Failed to build richclip" +} + +"richclip unicode ✓" | & $richclip copy --one-shot +if ($LASTEXITCODE -ne 0) { + throw "Failed to copy Unicode text" +} +Assert-Equal "richclip unicode ✓" (& $richclip paste) "richclip could not read its own Unicode text" +Assert-Equal "richclip unicode ✓" (Get-Clipboard -Raw).TrimEnd() "Windows could not read richclip Unicode text" + +Set-Clipboard -Value "windows unicode ✓" +Assert-Equal "windows unicode ✓" (& $richclip paste) "richclip could not read Windows Unicode text" + +"richclip html" | & $richclip copy --type text/plain --type text/html +if ($LASTEXITCODE -ne 0) { + throw "Failed to copy multiple formats" +} +$types = & $richclip paste --list-types +if ($types -notcontains "text/plain;charset=utf-8" -or $types -notcontains "text/html") { + throw "Expected text and HTML formats, got: $($types -join ', ')" +} +Assert-Equal "richclip html" (& $richclip paste --type text/html) "HTML round trip failed" + +"custom-data" | & $richclip copy --type application/x-richclip-test +if ($LASTEXITCODE -ne 0) { + throw "Failed to copy a custom format" +} +Assert-Equal "custom-data" (& $richclip paste --type application/x-richclip-test) "Custom format round trip failed"