diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90911a4..9284f8e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,19 +8,15 @@ on: jobs: rustfmt: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - override: true components: rustfmt - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Check formatting - uses: actions-rs/cargo@v1 - with: - command: fmt - args: --all -- --check + run: cargo fmt --all -- --check clippy: needs: rustfmt @@ -28,37 +24,36 @@ jobs: strategy: matrix: os: [macos-latest, windows-latest, ubuntu-latest] + # Latest stable and MSRV. We only run checks with all features enabled + # for the MSRV build to keep CI fast, since other configurations should also work. + rust_version: [stable, "1.71.0"] steps: - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: stable - override: true + toolchain: ${{ matrix.rust_version }} components: clippy - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Run `cargo clippy` with no features - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --verbose --no-default-features -- -D warnings -D clippy::dbg_macro + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features -- -D warnings -D clippy::dbg_macro - name: Run `cargo clippy` with `image-data` feature - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --verbose --no-default-features --features image-data -- -D warnings -D clippy::dbg_macro + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features --features image-data -- -D warnings -D clippy::dbg_macro - name: Run `cargo clippy` with `wayland-data-control` feature - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --verbose --no-default-features --features wayland-data-control -- -D warnings -D clippy::dbg_macro + if: ${{ matrix.rust_version == 'stable' }} + run: cargo clippy --verbose --no-default-features --features wayland-data-control -- -D warnings -D clippy::dbg_macro - name: Run `cargo clippy` with all features - uses: actions-rs/cargo@v1 - with: - command: clippy - args: --verbose --all-features -- -D warnings -D clippy::dbg_macro + run: cargo clippy --verbose --all-features -- -D warnings -D clippy::dbg_macro + + - name: Run `cargo clippy` with dependency version checks + if: ${{ matrix.rust_version == 'stable' }} + run: | + cargo update -p windows-sys + cargo clippy --verbose --all-features -- -D warnings -D clippy::dbg_macro test: needs: clippy @@ -68,32 +63,19 @@ jobs: # No Linux test for now as it just fails due to not having a desktop environment. os: [macos-latest, windows-latest] steps: - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: toolchain: stable - override: true - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Run tests with no features - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-default-features + run: cargo test --no-default-features - name: Run tests with `image-data` feature - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-default-features --features image-data + run: cargo test --no-default-features --features image-data - name: Run tests with `wayland-data-control` feature - uses: actions-rs/cargo@v1 - with: - command: test - args: --no-default-features --features wayland-data-control + run: cargo test --no-default-features --features wayland-data-control - name: Run tests with all features - uses: actions-rs/cargo@v1 - with: - command: test - args: --all-features + run: cargo test --all-features miri: needs: clippy @@ -105,17 +87,20 @@ jobs: # Currently, only Windows has soundness tests. os: [windows-latest] steps: - - uses: actions-rs/toolchain@v1 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - toolchain: nightly-2022-10-15 - override: true + toolchain: nightly-2023-10-08 components: miri - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Check soundness - uses: actions-rs/cargo@v1 - with: - command: miri - args: test windows --features image-data + run: cargo miri test windows --features image-data + + semver: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Check semver + uses: obi1kenobi/cargo-semver-checks-action@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index ac55179..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,119 +0,0 @@ -# Changelog - -## 3.2.1 on 2023-28-11 - -### Fixed -- Removed all leaks from the macOS clipboard code. Previously, both the `get` and `set` methods leaked data. -- Fixed documentation examples so that they compile on Linux. -- Removed extra whitespace macOS's HTML copying template. This caused unexpected behavior in some apps. - -### Changed -- Added a timeout when connecting to the X11 server on UNIX platforms. In situations where the X11 socket is present but unusable, the clipboard - initialization will no longer hang indefinitely. -- Removed macOS-specific dependency on the `once_cell` crate. - -## 3.2.0 on 2022-04-11 - -### Changed -- The Windows clipboard now behaves consistently with the other -platform implementations again. -- Significantly improve cross-platform documentation of `Clipboard`. -- Remove lingering uses of the dbg! macro in the Wayland backend. - -## 3.1.1 on 2022-17-10 - -### Added -- Implemented the ability to set HTML on the clipboard - -### Changed -- Updated minimum `clipboard-win` version to `4.4`. -- Updated `wl-clipboard-rs` to the version `0.7`. - -## 3.1 on 2022-20-09 - -### Changed -- Updated `image` to the version `0.24`. -- Lowered Wayland clipboard initialization log level. - -## 3.0 on 2022-19-09 - -### Added -- Support for clearing the clipboard. -- Spport for excluding Windows clipboard data from cliboard history and OneDrive. -- Support waiting for another process to read clipboard data before returning -from a `write` call to a X11 and Wayland or clipboard - -### Changed -- Updated `wl-clipboard-rs` to the version `0.6`. -- Updated `x11rb` to the version `0.10`. -- Cleaned up spelling in documentation -- (Breaking) Functions that used to accept `String` now take `Into, str>` instead. -This avoids cloning the string more times then necessary on platforms that can. -- (Breaking) `Error` is now marked as `#[non_exhaustive]`. -- (Breaking) Removed all platform specific modules and clipboard structures from the public API. -If you were using these directly, the recommended replacement is using `arboard::Clipboard` and -the new platform-specific extension traits instead. -- (Breaking) On Windows, the clipboard is now opened once per call to `Clipboard::new()` instead of on -each operation. This means that instances of `Clipboard` should be dropped once you're performed the -needed operations to prevent other applications from working with it afterwards. - -## v2.1.1 on 2022-18-05 - -### Changed - -- Fix compilation on FreeBSD -- Internal cleanup and documentation fixes -- Remove direct dependency on the `once_cell` crate. -- Fixed crates.io repository link - -## v2.1.0 on 2022-09-03 - -### Changed - -- Updated most dependencies -- Removed crate deprecation -- Fixed soundness bug in Windows clipboard - -## v2.0.1 on 2021-11-05 - -### Changed - -- On X11, re-assert clipboard ownership every time the data changes. - -## v2.0.0 on 2021-08-07 - -### Changed - -- Update dependency on yanked crate versions -- Make the image operations an optional feature - -### Added - -- Support selecting which linux clipboard is used - -## v1.2.1 on 2021-05-04 - -### Changed - -- Fixed a bug that caused the `set_image` function on Windows to distort the - image colors. - -## v1.2.0 on 2021-04-06 - -### Added - -- Optional native wayland support through the `wl-clipboard-rs` crate. - -## v1.1.0 on 2020-12-29 - -### Changed - -- The `set_image` function on Windows now also provides the image in - `CF_BITMAP` format. - -## v1.0.2 on 2020-10-29 - -### Changed - -- Fixed the clipboard contents sometimes not being preserved after the program - exited. diff --git a/Cargo.lock b/Cargo.lock index 982ab00..c940d24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,35 +19,24 @@ dependencies = [ [[package]] name = "arboard" -version = "3.2.1" +version = "3.6.1" dependencies = [ "clipboard-win", - "core-graphics", "env_logger", "image", "log", - "objc", - "objc-foundation", - "objc_id", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", "parking_lot", - "simple_logger", - "thiserror", - "winapi", + "percent-encoding", + "windows-sys", "wl-clipboard-rs", "x11rb", ] -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.1.0" @@ -61,16 +50,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "block" -version = "0.1.6" +name = "bitflags" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "bytecount" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c676a478f63e9fa2dd5368a42f28bba0d6c560b775f38583c8bbaa7fcd67c9c" +checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" [[package]] name = "bytemuck" @@ -84,6 +67,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "cc" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f" + [[package]] name = "cfg-if" version = "1.0.0" @@ -92,71 +81,11 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clipboard-win" -version = "4.4.2" +version = "5.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ab1b92798304eedc095b53942963240037c0516452cb11aeba709d420b2219" +checksum = "79f4473f5144e20d9aceaf2972478f06ddf687831eafeeb434fbaf0acc4144ad" dependencies = [ "error-code", - "str-buf", - "winapi", -] - -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - -[[package]] -name = "colored" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3616f750b84d8f0de8a58bda93e08e2a81ad3f523089b05f1dffecab48c6cbd" -dependencies = [ - "atty", - "lazy_static", - "winapi", -] - -[[package]] -name = "core-foundation" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" - -[[package]] -name = "core-graphics" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2581bbab3b8ffc6fcbd550bf46c355135d16e9ff2a6ea032ad6b9bf1d7efe4fb" -dependencies = [ - "bitflags", - "core-foundation", - "core-graphics-types", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a68b68b3446082644c91ac778bf50cd4104bfb002b5a6a7c44cca5a2c70788b" -dependencies = [ - "bitflags", - "core-foundation", - "foreign-types", - "libc", ] [[package]] @@ -168,17 +97,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "derive-new" -version = "0.5.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "downcast-rs" version = "1.2.0" @@ -187,35 +105,38 @@ checksum = "9ea835d29036a4087793836fa931b08837ad5e957da9e23886b29586fb9b6650" [[package]] name = "env_logger" -version = "0.9.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c90bf5f19754d10198ccb95b70664fc925bd1fc090a0fd9a6ebc54acc8cd6272" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" dependencies = [ - "atty", "humantime", + "is-terminal", "log", "regex", "termcolor", ] [[package]] -name = "error-code" -version = "2.3.1" +name = "errno" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64f18991e7bf11e7ffee451b5318b5c1a73c52d0d0ada6e5a3017c8c1ced6a21" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "str-buf", + "windows-sys", ] [[package]] -name = "fastrand" -version = "1.8.0" +name = "error-code" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" -dependencies = [ - "instant", -] +checksum = "281e452d3bad4005426416cdba5ccfd4f5c1280e10099e21db27f7c1c28347fc" + +[[package]] +name = "fastrand" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" [[package]] name = "fixedbitset" @@ -239,29 +160,14 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "gethostname" -version = "0.2.3" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" dependencies = [ "libc", - "winapi", + "windows-targets 0.48.0", ] [[package]] @@ -272,12 +178,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hermit-abi" -version = "0.1.19" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] +checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" [[package]] name = "humantime" @@ -287,14 +190,12 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "image" -version = "0.24.3" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e30ca2ecf7666107ff827a8e481de6a132a9b687ed3bb20bb1c144a36c00964" +checksum = "fd54d660e773627692c524beaad361aca785a4f9f5730ce91f42aabe5bce3d11" dependencies = [ "bytemuck", "byteorder", - "color_quant", - "num-rational", "num-traits", "png", "tiff", @@ -311,37 +212,33 @@ dependencies = [ ] [[package]] -name = "instant" -version = "0.1.12" +name = "is-terminal" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +checksum = "e19b23d53f35ce9f56aebc7d1bb4e6ac1e9c0db7ac85c8d1760c04379edced37" dependencies = [ - "cfg-if", + "hermit-abi", + "libc", + "windows-sys", ] -[[package]] -name = "itoa" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" - [[package]] name = "jpeg-decoder" -version = "0.2.6" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9478aa10f73e7528198d75109c8be5cd7d15fb530238040148d5f9a22d4c5b3b" - -[[package]] -name = "lazy_static" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" [[package]] name = "libc" -version = "0.2.133" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f80d65747a3e43d1596c7c5492d95d5edddaabd45a7fcdb02b95f644164966" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "lock_api" @@ -355,21 +252,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.17" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" @@ -377,15 +262,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" -[[package]] -name = "memoffset" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" -dependencies = [ - "autocfg", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -401,18 +277,6 @@ dependencies = [ "adler", ] -[[package]] -name = "nix" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "195cdbc1741b8134346d515b3a56a1c94b0912758009cfd53f99ea0f57b065fc" -dependencies = [ - "bitflags", - "cfg-if", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.1" @@ -423,27 +287,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "num-integer" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.15" @@ -454,41 +297,74 @@ dependencies = [ ] [[package]] -name = "num_threads" -version = "0.1.6" +name = "objc2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "3531f65190d9cff863b77a99857e74c314dd16bf56c538c4b57c7cbc3f3a6e59" dependencies = [ - "libc", + "objc2-encode", ] [[package]] -name = "objc" -version = "0.2.7" +name = "objc2-app-kit" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +checksum = "5906f93257178e2f7ae069efb89fbd6ee94f0592740b5f8a1512ca498814d0fb" dependencies = [ - "malloc_buf", + "bitflags 2.8.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", ] [[package]] -name = "objc-foundation" -version = "0.1.1" +name = "objc2-core-foundation" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +checksum = "daeaf60f25471d26948a1c2f840e3f7d86f4109e3af4e8e4b5cd70c39690d925" dependencies = [ - "block", - "objc", - "objc_id", + "bitflags 2.8.0", + "objc2", ] [[package]] -name = "objc_id" -version = "0.1.1" +name = "objc2-core-graphics" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +checksum = "f8dca602628b65356b6513290a21a6405b4d4027b8b250f0b98dddbb28b7de02" dependencies = [ - "objc", + "bitflags 2.8.0", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a21c6c9014b82c39515db5b396f91645182611c97d24637cf56ac01e5f8d998" +dependencies = [ + "bitflags 2.8.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161a8b87e32610086e1a7a9e9ec39f84459db7b3a0881c1f16ca5a2605581c19" +dependencies = [ + "bitflags 2.8.0", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -499,12 +375,12 @@ checksum = "e82dad04139b71a90c080c8463fe0dc7902db5192d939bd0950f074d014339e1" [[package]] name = "os_pipe" -version = "1.0.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c92f2b54f081d635c77e7120862d48db8e91f7f21cef23ab1b4fe9971c59f55" +checksum = "29d73ba8daf8fac13b0501d1abeddcfe21ba7401ada61a819144b6c2a4f32209" dependencies = [ "libc", - "winapi", + "windows-sys", ] [[package]] @@ -519,17 +395,23 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.3" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-targets 0.52.6", ] +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + [[package]] name = "petgraph" version = "0.6.2" @@ -552,7 +434,7 @@ version = "0.17.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f0e7f4c94ec26ff209cee506314212639d6c91b80afb82984819fafce9df01c" dependencies = [ - "bitflags", + "bitflags 1.3.2", "crc32fast", "flate2", "miniz_oxide", @@ -560,29 +442,38 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] -name = "quote" -version = "1.0.21" +name = "quick-xml" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "165859e9e55f79d67b96c5d96f4e88b6f2695a1972849c15a6a3f5c59fc2c003" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] [[package]] name = "redox_syscall" -version = "0.2.16" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -603,12 +494,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3f87b73ce11b1619a3c6332f45341e0047173771e8b8b73f87bfeefb7b56244" [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "rustix" +version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "winapi", + "bitflags 2.8.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] @@ -617,31 +512,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" -[[package]] -name = "simple_logger" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48047e77b528151aaf841a10a9025f9459da80ba820e425ff7eb005708a76dc7" -dependencies = [ - "atty", - "colored", - "log", - "time", - "winapi", -] - [[package]] name = "smallvec" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" -[[package]] -name = "str-buf" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" - [[package]] name = "syn" version = "1.0.100" @@ -655,23 +531,21 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.3.0" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "rustix", + "windows-sys", ] [[package]] name = "termcolor" -version = "1.1.3" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ "winapi-util", ] @@ -698,42 +572,23 @@ dependencies = [ [[package]] name = "tiff" -version = "0.7.3" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7259662e32d1e219321eb309d5f9d898b779769d81b76e762c07c8e5d38fcb65" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" dependencies = [ "flate2", "jpeg-decoder", "weezl", ] -[[package]] -name = "time" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3f9a28b618c3a6b9251b6908e9c99e04b9e5c02e6581ccbb67d59c34ef7f9b" -dependencies = [ - "itoa", - "libc", - "num_threads", - "time-macros", -] - -[[package]] -name = "time-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42657b1a6f4d817cda8e7a0ace261fe0cc946cf3a80314390b22cc61ae080792" - [[package]] name = "tree_magic_mini" -version = "3.0.3" +version = "3.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91adfd0607cacf6e4babdb870e9bec4037c1c4b151cfd279ccefc5e0c7feaa6d" +checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63" dependencies = [ - "bytecount", "fnv", - "lazy_static", + "memchr", "nom", "once_cell", "petgraph", @@ -746,60 +601,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" [[package]] -name = "wayland-client" -version = "0.29.5" +name = "wayland-backend" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3b068c05a039c9f755f881dc50f01732214f5685e379829759088967c46715" +checksum = "b7208998eaa3870dad37ec8836979581506e0c5c64c20c9e79e9d2a10d6f47bf" dependencies = [ - "bitflags", + "cc", "downcast-rs", - "libc", - "nix", - "wayland-commons", - "wayland-scanner", - "wayland-sys", -] - -[[package]] -name = "wayland-commons" -version = "0.29.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691f134d584a33a6606d9d717b95c4fa20065605f798a3f350d78dced02a902" -dependencies = [ - "nix", - "once_cell", + "rustix", "smallvec", "wayland-sys", ] [[package]] -name = "wayland-protocols" -version = "0.29.5" +name = "wayland-client" +version = "0.31.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b950621f9354b322ee817a23474e479b34be96c2e909c14f7bc0100e9a970bc6" +checksum = "c2120de3d33638aaef5b9f4472bff75f07c56379cf76ea320bd3a3d65ecaf73f" dependencies = [ - "bitflags", + "bitflags 2.8.0", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0781cf46869b37e36928f7b432273c0995aa8aed9552c556fb18754420541efc" +dependencies = [ + "bitflags 2.8.0", + "wayland-backend", "wayland-client", - "wayland-commons", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248a02e6f595aad796561fa82d25601bd2c8c3b145b1c7453fc8f94c1a58f8b2" +dependencies = [ + "bitflags 2.8.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", "wayland-scanner", ] [[package]] name = "wayland-scanner" -version = "0.29.5" +version = "0.31.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4303d8fa22ab852f789e75a967f0a2cdc430a607751c0499bada3e451cbd53" +checksum = "896fdafd5d28145fce7958917d69f2fd44469b1d4e861cb5961bcbeebc6d1484" dependencies = [ "proc-macro2", + "quick-xml", "quote", - "xml-rs", ] [[package]] name = "wayland-sys" -version = "0.29.5" +version = "0.31.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be12ce1a3c39ec7dba25594b97b42cb3195d54953ddb9d3d95a7c3902bc6e9d4" +checksum = "dbcebb399c77d5aa9fa5db874806ee7b4eba4e73650948e8f93963f128896615" dependencies = [ "pkg-config", ] @@ -810,131 +676,177 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9193164d4de03a926d909d3bc7c30543cecb35400c02114792c2cae20d5e2dbb" -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "winapi", + "windows-sys", ] -[[package]] -name = "winapi-wsapoll" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c17110f57155602a80dca10be03852116403c9ff3cd25b079d666f2aa3df6e" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" -version = "0.36.1" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_msvc", + "windows-targets 0.52.6", ] [[package]] -name = "windows_aarch64_msvc" -version = "0.36.1" +name = "windows-targets" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" +checksum = "7b1eb6f0cd7c80c79759c929114ef071b87354ce476d9d94271031c0497adfd5" +dependencies = [ + "windows_aarch64_gnullvm 0.48.0", + "windows_aarch64_msvc 0.48.0", + "windows_i686_gnu 0.48.0", + "windows_i686_msvc 0.48.0", + "windows_x86_64_gnu 0.48.0", + "windows_x86_64_gnullvm 0.48.0", + "windows_x86_64_msvc 0.48.0", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_i686_gnu" -version = "0.36.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" +checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_msvc" -version = "0.36.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" +checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_x86_64_gnu" -version = "0.36.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" +checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_msvc" -version = "0.36.1" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" +checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "wl-clipboard-rs" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "981a303dfbb75d659f6612d05a14b2e363c103d24f676a2d44a00d18507a1ad9" +checksum = "4de22eebb1d1e2bad2d970086e96da0e12cde0b411321e5b0f7b2a1f876aa26f" dependencies = [ - "derive-new", "libc", "log", - "nix", "os_pipe", + "rustix", "tempfile", "thiserror", "tree_magic_mini", + "wayland-backend", "wayland-client", "wayland-protocols", + "wayland-protocols-wlr", ] [[package]] name = "x11rb" -version = "0.10.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "592b4883219f345e712b3209c62654ebda0bb50887f330cbd018d0f654bfd507" +checksum = "f8f25ead8c7e4cba123243a6367da5d3990e0d3affa708ea19dce96356bd9f1a" dependencies = [ "gethostname", - "nix", - "winapi", - "winapi-wsapoll", + "rustix", "x11rb-protocol", ] [[package]] name = "x11rb-protocol" -version = "0.10.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56b245751c0ac9db0e006dc812031482784e434630205a93c73cfefcaabeac67" -dependencies = [ - "nix", -] - -[[package]] -name = "xml-rs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" +checksum = "e63e71c4b8bd9ffec2c963173a4dc4cbde9ee96961d4fcb4429db9929b606c34" diff --git a/Cargo.toml b/Cargo.toml index 567a5d5..fae5b7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,48 +1,91 @@ [package] name = "arboard" -version = "3.2.1" -authors = ["Artur Kovacs ", "Avi Weinstock ", "Arboard contributors"] +version = "3.6.1" description = "Image and text handling for the OS clipboard." repository = "https://github.com/1Password/arboard" license = "MIT OR Apache-2.0" readme = "README.md" keywords = ["clipboard", "image"] -edition = "2018" +edition = "2021" +rust-version = "1.71.0" [features] default = ["image-data"] -image-data = ["core-graphics", "image", "winapi/minwindef", "winapi/wingdi", "winapi/winnt"] +image-data = [ + "dep:objc2-core-graphics", + "dep:objc2-core-foundation", + "image", + "windows-sys", + "core-graphics", +] wayland-data-control = ["wl-clipboard-rs"] +# For backwards compat +core-graphics = ["dep:objc2-core-graphics"] +windows-sys = ["windows-sys/Win32_Graphics_Gdi"] +image = ["dep:image"] +wl-clipboard-rs = ["dep:wl-clipboard-rs"] + [dependencies] -thiserror = "1.0" [dev-dependencies] -simple_logger = "2.1" -env_logger = "0.9.0" +env_logger = "0.10.2" [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3.9", features = [ - "basetsd", - "winuser", - "winbase", -]} -clipboard-win = "4.4.2" +windows-sys = { version = ">=0.52.0, <0.61.0", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_System_DataExchange", + "Win32_System_Memory", + "Win32_System_Ole", + "Win32_UI_Shell", +] } +clipboard-win = { version = "5.3.1", features = ["std"] } log = "0.4" +image = { version = "0.25", optional = true, default-features = false, features = [ + "png", "bmp" +] } [target.'cfg(target_os = "macos")'.dependencies] -objc = "0.2" -objc_id = "0.1" -objc-foundation = "0.1" -core-graphics = { version = "0.22", optional = true } -image = { version = "0.24", optional = true, default-features = false, features = ["tiff"] } +objc2 = "0.6.0" +objc2-foundation = { version = "0.3.0", default-features = false, features = [ + "std", + "NSArray", + "NSString", + "NSEnumerator", + "NSGeometry", + "NSValue", +] } +objc2-app-kit = { version = "0.3.0", default-features = false, features = [ + "std", + "objc2-core-graphics", + "NSPasteboard", + "NSPasteboardItem", + "NSImage", +] } +objc2-core-foundation = { version = "0.3.0", default-features = false, optional = true, features = [ + "std", + "CFCGTypes", +] } +objc2-core-graphics = { version = "0.3.0", default-features = false, optional = true, features = [ + "std", + "CGImage", + "CGColorSpace", + "CGDataProvider", +] } +image = { version = "0.25", optional = true, default-features = false, features = [ + "tiff", +] } [target.'cfg(all(unix, not(any(target_os="macos", target_os="android", target_os="emscripten"))))'.dependencies] log = "0.4" -x11rb = { version = "0.10" } -wl-clipboard-rs = { version = "0.7", optional = true } -image = { version = "0.24", optional = true, default-features = false, features = ["png"] } +x11rb = { version = "0.13" } +wl-clipboard-rs = { version = "0.9.0", optional = true } +image = { version = "0.25", optional = true, default-features = false, features = [ + "png", +] } parking_lot = "0.12" +percent-encoding = "2.3.1" [[example]] name = "get_image" diff --git a/README.md b/README.md index cbef596..5c5111c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Latest version](https://img.shields.io/crates/v/arboard?color=mediumvioletred)](https://crates.io/crates/arboard) [![Documentation](https://docs.rs/arboard/badge.svg)](https://docs.rs/arboard) +![MSRV](https://img.shields.io/badge/rustc-1.71.0+-blue.svg) ## General @@ -9,22 +10,67 @@ This is a cross-platform library for interacting with the clipboard. It allows to copy and paste both text and image data in a platform independent way on Linux, Mac, and Windows. +Please note that this is not an official 1Password product. Feature requests will be considered like any other volunteer-based crate. + ## GNU/Linux -The GNU/Linux implementation uses the X protocol by default for managing the -clipboard but *fear not* because Wayland works with the X11 protocol just as -well. Furthermore this implementation uses the Clipboard selection (as opposed -to the primary selection) and it sends the data to the clipboard manager when -the application exits so that the data placed onto the clipboard with your -application remains to be available after exiting. +### Backend Support -There's also an optional wayland data control backend through the -`wl-clipboard-rs` crate. This can be enabled using the `wayland-data-control` -feature. When enabled this will be prioritized over the X11 backend, but if the -initialization fails, the implementation falls back to using the X11 protocol -automatically. Note that in my tests the wayland backend did not keep the -clipboard contents after the process exited. (Although neither did the X11 -backend on my Wayland setup). +By default, `arboard`'s backend on Linux supports X11 (or XWayland implementations) and uses +that for managing the various Linux clipboard variants. This supports the majority of desktop +environments that exist in the wild today. `arboard` will use the `Clipboard` selection by default, +but the [LinuxClipboardKind](https://docs.rs/arboard/latest/arboard/enum.LinuxClipboardKind.html) +selector lets you operate on the `Primary` or `Secondary` clipboard selections (if supported). + +However, Wayland is becoming the majority default as of 2025. Some distributions are +even considering the removal of X by default. To support Wayland correctly, `arboard` users +should enable the `wayland-data-control` feature. If enabled, it will be prioritized over the X clipboard. + +Wayland support is not enabled by default because it may be counterintuitive +to some users: it relies on the data-control protocol extension(s), which _are not_ +supported by all Wayland compositors. You can check compositor support on `wayland.app`: +- [ext-data-control-v1](https://wayland.app/protocols/ext-data-control-v1) +- [wlr-data-control-unstable-v1](https://wayland.app/protocols/wlr-data-control-unstable-v1) + +If you or a user's desktop doesn't support these protocols, `arboard` won't function in a pure +Wayland environment. It is recommended to enable `XWayland` for these cases. If your app runs inside +an isolated sandbox, such as Flatpak or Snap, you'll need to expose the X11 socket to the application +_in addition_ to the Wayland communication interface. + +### Clipboard Ownership + +Some apps and users may notice that sometimes values copied to a Linux clipboard with this crate vanish +before anyone gets the chance to paste, or just aren't available when you expect them to be. The root behind +these problems is _selection ownership_. + +X11 and Wayland put the responsibility for answering paste requests and serving data on the application +which originally copied it onto the clipboard. This usually means the app using `arboard`. Nothing you copy +to the clipboard is sent anywhere to start. It stays inside `arboard` until something else on the system requests it, +which is very different to how the clipboard works on other platforms like macOS or Windows. + +Note that `arboard` may attempt to warn you about these conditions when compiled in debug mode, to improve the debugging +experience. Even if you don't see these warnings, you should double check the lifetime of the `Clipboard` in your code. + +In some cases, an environment may have a clipboard manager installed. These services monitor the clipboard contents and +do their best to retain a copy when needed to smooth over clipboard ownership changes. A clipboar manager can make contents +available even after a process previously owning it exits. + +In order to keep the contents around longer, make sure that you don't `Drop` your `Clipboard` object right away or +terminate the copying process too fast. This is why, at times, adding a call to `sleep()` near the set operation +makes it behave more reliabily: the background thread `arboard` uses for serving clipboard contents has more time to run +and let other apps (including clipboard managers) make requests for the contents. However `sleep` isn't the recommended approach. + +If your application is exiting, you must make sure there is a clipboard manager running on the system. If nothing is listening for +the clipboard ownership transfer, or made a copy previously, the data will be lost. Note that this isn't a complete +guarantee as races are possible if your program's main thread is exiting. If you would like to fully synchronize the clipboard "paste" +before exiting, you can use the [wait](https://docs.rs/arboard/latest/arboard/trait.SetExtLinux.html#tymethod.wait) method when setting +contents on the clipboard. This will block the calling thread until another app has requested, and then received, the data. + +If your application is longer-running (ie a GUI, TUI, etc), it is highly recommended that you either store the `Clipboard` object in some +long-lived data structure (like app context, etc) or utilize `wait` method mentioned above, and/or threading to make sure another +app can request the clipboard data later. + +We welcome suggestions to improve on the above issues in ways that don't degrade other use cases. ## Example @@ -41,12 +87,31 @@ fn main() { } ``` -## Yet another clipboard crate +## Credits -This is a fork of `rust-clipboard`. The reason for forking instead of making a -PR is that `rust-clipboard` is not being maintained any more. Furthermore note -that the API of this crate is considerably different from that of -`rust-clipboard`. There are already a ton of clipboard crates out there which -is a bit unfortunate; I don't know why this is happening but while it is, we -might as well just start naming the clipboard crates after ourselves. This one -is arboard which stands for Artur's clipboard. +This crate is a combined effort by 1Password staff and `@ArturKovacs`, the crate's past +maintainer. + +#### License + + +Licensed under either of Apache License, Version +2.0 or MIT license at your option. + + +
+ + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in this crate by you, as defined in the Apache-2.0 license, shall +be dual licensed as above, without any additional terms or conditions. + + +#### History: Yet another clipboard crate + +This crate started out as a fork of `rust-clipboard`. The reason for forking is due to the former +crate not being maintained any longer. At this point, `arboard`'s backends and public APIs have diverged +a lot. + +`arboard`'s original maintainer noted that "I don't know why this is happening but while it is, we might +as well just start naming the clipboard crates after ourselves. This one is arboard which stands for Artur's clipboard.". diff --git a/examples/daemonize.rs b/examples/daemonize.rs index b657ff6..94986dd 100644 --- a/examples/daemonize.rs +++ b/examples/daemonize.rs @@ -4,7 +4,6 @@ use arboard::Clipboard; #[cfg(target_os = "linux")] use arboard::SetExtLinux; -use simple_logger::SimpleLogger; use std::{env, error::Error, process}; // An argument that can be passed into the program to signal that it should daemonize itself. This @@ -18,7 +17,7 @@ fn main() -> Result<(), Box> { return Ok(()); } - SimpleLogger::new().init().unwrap(); + env_logger::init(); if cfg!(target_os = "linux") { process::Command::new(env::current_exe()?) diff --git a/examples/hello_world.rs b/examples/hello_world.rs index 07f8c19..6c8376c 100644 --- a/examples/hello_world.rs +++ b/examples/hello_world.rs @@ -1,12 +1,11 @@ use arboard::Clipboard; -use simple_logger::SimpleLogger; fn main() { - SimpleLogger::new().init().unwrap(); + env_logger::init(); let mut clipboard = Clipboard::new().unwrap(); println!("Clipboard text was: {:?}", clipboard.get_text()); let the_string = "Hello, world!"; clipboard.set_text(the_string).unwrap(); - println!("But now the clipboard text should be: \"{}\"", the_string); + println!("But now the clipboard text should be: \"{the_string}\""); } diff --git a/examples/set_html.rs b/examples/set_get_html.rs similarity index 74% rename from examples/set_html.rs rename to examples/set_get_html.rs index 2662d31..96ab58a 100644 --- a/examples/set_html.rs +++ b/examples/set_get_html.rs @@ -1,9 +1,8 @@ use arboard::Clipboard; -use simple_logger::SimpleLogger; use std::{thread, time::Duration}; fn main() { - SimpleLogger::new().init().unwrap(); + env_logger::init(); let mut ctx = Clipboard::new().unwrap(); let html = r#"

Hello, World!

@@ -16,4 +15,7 @@ consectetur adipiscing elit."#; ctx.set_html(html, Some(alt_text)).unwrap(); thread::sleep(Duration::from_secs(5)); + + let success = ctx.get().html().unwrap() == html; + println!("Set and Get html operations were successful: {success}"); } diff --git a/src/common.rs b/src/common.rs index 95c63f6..b5e877b 100644 --- a/src/common.rs +++ b/src/common.rs @@ -10,20 +10,17 @@ and conditions of the chosen license apply to this file. #[cfg(feature = "image-data")] use std::borrow::Cow; -use thiserror::Error; /// An error that might happen during a clipboard operation. /// /// Note that both the `Display` and the `Debug` trait is implemented for this type in such a way /// that they give a short human-readable description of the error; however the documentation /// gives a more detailed explanation for each error kind. -#[derive(Error)] #[non_exhaustive] pub enum Error { /// The clipboard contents were not available in the requested format. /// This could either be due to the clipboard being empty or the clipboard contents having /// an incompatible format to the requested one (eg when calling `get_image` on text) - #[error("The clipboard contents were not available in the requested format or the clipboard is empty.")] ContentNotAvailable, /// The selected clipboard is not supported by the current configuration (system and/or environment). @@ -31,10 +28,9 @@ pub enum Error { /// This can be caused by a few conditions: /// - Using the Primary clipboard with an older Wayland compositor (that doesn't support version 2) /// - Using the Secondary clipboard on Wayland - #[error("The selected clipboard is not supported with the current system configuration.")] ClipboardNotSupported, - /// The native clipboard is not accessible due to being held by an other party. + /// The native clipboard is not accessible due to being held by another party. /// /// This "other party" could be a different process or it could be within /// the same program. So for example you may get this error when trying @@ -43,25 +39,33 @@ pub enum Error { /// Note that it's OK to have multiple `Clipboard` instances. The underlying /// implementation will make sure that the native clipboard is only /// opened for transferring data and then closed as soon as possible. - #[error("The native clipboard is not accessible due to being held by an other party.")] ClipboardOccupied, - /// This can happen in either of the following cases. - /// - /// - When returned from `set_image`: the image going to the clipboard cannot be converted to the appropriate format. - /// - When returned from `get_image`: the image coming from the clipboard could not be converted into the `ImageData` struct. - /// - When returned from `get_text`: the text coming from the clipboard is not valid utf-8 or cannot be converted to utf-8. - #[error("The image or the text that was about the be transferred to/from the clipboard could not be converted to the appropriate format.")] + /// The image or the text that was about the be transferred to/from the clipboard could not be + /// converted to the appropriate format. ConversionFailure, /// Any error that doesn't fit the other error types. /// /// The `description` field is only meant to help the developer and should not be relied on as a /// means to identify an error case during runtime. - #[error("Unknown error while interacting with the clipboard: {description}")] Unknown { description: String }, } +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::ContentNotAvailable => f.write_str("The clipboard contents were not available in the requested format or the clipboard is empty."), + Error::ClipboardNotSupported => f.write_str("The selected clipboard is not supported with the current system configuration."), + Error::ClipboardOccupied => f.write_str("The native clipboard is not accessible due to being held by another party."), + Error::ConversionFailure => f.write_str("The image or the text that was about the be transferred to/from the clipboard could not be converted to the appropriate format."), + Error::Unknown { description } => f.write_fmt(format_args!("Unknown error while interacting with the clipboard: {description}")), + } + } +} + +impl std::error::Error for Error {} + impl std::fmt::Debug for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { use Error::*; @@ -81,7 +85,13 @@ impl std::fmt::Debug for Error { ConversionFailure, Unknown { .. } ); - f.write_fmt(format_args!("{} - \"{}\"", name, self)) + f.write_fmt(format_args!("{name} - \"{self}\"")) + } +} + +impl Error { + pub(crate) fn unknown>(message: M) -> Self { + Error::Unknown { description: message.into() } } } @@ -121,7 +131,7 @@ pub struct ImageData<'a> { } #[cfg(feature = "image-data")] -impl<'a> ImageData<'a> { +impl ImageData<'_> { /// Returns a the bytes field in a way that it's guaranteed to be owned. /// It moves the bytes if they are already owned and clones them if they are borrowed. pub fn into_owned_bytes(self) -> Cow<'static, [u8]> { diff --git a/src/lib.rs b/src/lib.rs index fbbd0b8..13eb5ff 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,9 +7,13 @@ The project to which this file belongs is licensed under either of the Apache 2.0 or the MIT license at the licensee's choice. The terms and conditions of the chosen license apply to this file. */ +#![warn(unreachable_pub)] mod common; -use std::borrow::Cow; +use std::{ + borrow::Cow, + path::{Path, PathBuf}, +}; pub use common::Error; #[cfg(feature = "image-data")] @@ -26,6 +30,9 @@ pub use platform::{ClearExtLinux, GetExtLinux, LinuxClipboardKind, SetExtLinux}; #[cfg(windows)] pub use platform::SetExtWindows; +#[cfg(target_os = "macos")] +pub use platform::SetExtApple; + /// The OS independent struct for accessing the clipboard. /// /// Any number of `Clipboard` instances are allowed to exist at a single point in time. Note however @@ -42,7 +49,7 @@ pub use platform::SetExtWindows; /// /// `arboard` does its best to abstract over different platforms, but sometimes the platform-specific /// behavior leaks through unsolvably. These differences, depending on which platforms are being targeted, -/// may affect your app's clipboard architecture (ex, opening and closing a [Clipboard] every time +/// may affect your app's clipboard architecture (ex, opening and closing a [`Clipboard`] every time /// or keeping one open in some application/global state). /// /// ## Linux @@ -54,33 +61,52 @@ pub use platform::SetExtWindows; /// ## Windows /// /// The clipboard on Windows is a global object, which may only be opened on one thread at once. -/// This means that `arboard` only truly opens the clipboard during each operation to ensure that -/// multiple `Clipboard`'s may exist at once. This also means that attempting operations in parallel -/// has a high likelyhood to return an error instead. +/// This means that `arboard` only truly opens the clipboard during each operation to prevent +/// multiple `Clipboard`s from existing at once. +/// +/// This means that attempting operations in parallel has a high likelihood to return an error or +/// deadlock. As such, it is recommended to avoid creating/operating clipboard objects on >1 thread. #[allow(rustdoc::broken_intra_doc_links)] pub struct Clipboard { pub(crate) platform: platform::Clipboard, } impl Clipboard { - /// Creates an instance of the clipboard + /// Creates an instance of the clipboard. + /// + /// # Errors + /// + /// On some platforms or desktop environments, an error can be returned if clipboards are not + /// supported. This may be retried. pub fn new() -> Result { Ok(Clipboard { platform: platform::Clipboard::new()? }) } - /// Fetches utf-8 text from the clipboard and returns it. + /// Fetches UTF-8 text from the clipboard and returns it. + /// + /// # Errors + /// + /// Returns error if clipboard is empty or contents are not UTF-8 text. pub fn get_text(&mut self) -> Result { self.get().text() } - /// Places the text onto the clipboard. Any valid utf-8 string is accepted. + /// Places the text onto the clipboard. Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if `text` failed to be stored on the clipboard. pub fn set_text<'a, T: Into>>(&mut self, text: T) -> Result<(), Error> { self.set().text(text) } /// Places the HTML as well as a plain-text alternative onto the clipboard. /// - /// Any valid utf-8 string is accepted. + /// Any valid UTF-8 string is accepted. + /// + /// # Errors + /// + /// Returns error if both `html` and `alt_text` failed to be stored on the clipboard. pub fn set_html<'a, T: Into>>( &mut self, html: T, @@ -94,6 +120,11 @@ impl Clipboard { /// Any image data placed on the clipboard with `set_image` will be possible read back, using /// this function. However it's of not guaranteed that an image placed on the clipboard by any /// other application will be of a supported format. + /// + /// # Errors + /// + /// Returns error if clipboard is empty, contents are not an image, or the contents cannot be + /// converted to an appropriate format and stored in the [`ImageData`] type. #[cfg(feature = "image-data")] pub fn get_image(&mut self) -> Result, Error> { self.get().image() @@ -106,6 +137,11 @@ impl Clipboard { /// - On macOS: `NSImage` object /// - On Linux: PNG, under the atom `image/png` /// - On Windows: In order of priority `CF_DIB` and `CF_BITMAP` + /// + /// # Errors + /// + /// Returns error if `image` cannot be converted to an appropriate format or if it failed to be + /// stored on the clipboard. #[cfg(feature = "image-data")] pub fn set_image(&mut self, image: ImageData) -> Result<(), Error> { self.set().image(image) @@ -113,6 +149,10 @@ impl Clipboard { /// Clears any contents that may be present from the platform's default clipboard, /// regardless of the format of the data. + /// + /// # Errors + /// + /// Returns error on Windows or Linux if clipboard cannot be cleared. pub fn clear(&mut self) -> Result<(), Error> { self.clear_with().default() } @@ -155,6 +195,16 @@ impl Get<'_> { pub fn image(self) -> Result, Error> { self.platform.image() } + + /// Completes the "get" operation by fetching HTML from the clipboard. + pub fn html(self) -> Result { + self.platform.html() + } + + /// Completes the "get" operation by fetching a list of file paths from the clipboard. + pub fn file_list(self) -> Result, Error> { + self.platform.file_list() + } } /// A builder for an operation that sets a value to the clipboard. @@ -196,6 +246,11 @@ impl Set<'_> { pub fn image(self, image: ImageData) -> Result<(), Error> { self.platform.image(image) } + + /// Completes the "set" operation by placing a list of file paths onto the clipboard. + pub fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + self.platform.file_list(file_list) + } } /// A builder for an operation that clears the data from the clipboard. @@ -258,7 +313,7 @@ mod tests { match ctx.get_text() { Ok(text) => assert!(text.is_empty()), Err(Error::ContentNotAvailable) => {} - Err(e) => panic!("unexpected error: {}", e), + Err(e) => panic!("unexpected error: {e}"), }; // confirm it is OK to clear when already empty. @@ -273,7 +328,7 @@ mod tests { match ctx.get_text() { Ok(text) => assert!(text.is_empty()), Err(Error::ContentNotAvailable) => {} - Err(e) => panic!("unexpected error: {}", e), + Err(e) => panic!("unexpected error: {e}"), }; } { @@ -285,6 +340,37 @@ mod tests { ctx.set_html(html, Some(alt_text)).unwrap(); assert_eq!(ctx.get_text().unwrap(), alt_text); } + { + let mut ctx = Clipboard::new().unwrap(); + + let html = "hello world!"; + + ctx.set().html(html, None).unwrap(); + + if cfg!(target_os = "macos") { + // Copying HTML on macOS adds wrapper content to work around + // historical platform bugs. We control this wrapper, so we are + // able to check that the full user data still appears and at what + // position in the final copy contents. + let content = ctx.get().html().unwrap(); + assert!(content.ends_with(&format!("{html}"))); + } else { + assert_eq!(ctx.get().html().unwrap(), html); + } + } + { + let mut ctx = Clipboard::new().unwrap(); + + let this_dir = env!("CARGO_MANIFEST_DIR"); + + let paths = &[ + PathBuf::from(this_dir).join("README.md"), + PathBuf::from(this_dir).join("Cargo.toml"), + ]; + + ctx.set().file_list(paths).unwrap(); + assert_eq!(ctx.get().file_list().unwrap().as_slice(), paths); + } #[cfg(feature = "image-data")] { let mut ctx = Clipboard::new().unwrap(); @@ -304,7 +390,7 @@ mod tests { ctx.set_text("clipboard test").unwrap(); assert!(matches!(ctx.get_image(), Err(Error::ContentNotAvailable))); - // Test if we get the same image that we put onto the clibboard + // Test if we get the same image that we put onto the clipboard ctx.set_image(img_data.clone()).unwrap(); let got = ctx.get_image().unwrap(); assert_eq!(img_data.bytes, got.bytes); diff --git a/src/platform/linux/mod.rs b/src/platform/linux/mod.rs index c51d4ed..b9e4095 100644 --- a/src/platform/linux/mod.rs +++ b/src/platform/linux/mod.rs @@ -1,19 +1,29 @@ -use std::borrow::Cow; +use std::{ + borrow::Cow, + os::unix::ffi::OsStrExt, + path::{Path, PathBuf}, + time::Instant, +}; #[cfg(feature = "wayland-data-control")] use log::{trace, warn}; +use percent_encoding::{percent_decode, percent_encode, AsciiSet, CONTROLS}; #[cfg(feature = "image-data")] use crate::ImageData; use crate::{common::private, Error}; +// Magic strings used in `Set::exclude_from_history()` on linux +const KDE_EXCLUSION_MIME: &str = "x-kde-passwordManagerHint"; +const KDE_EXCLUSION_HINT: &[u8] = b"secret"; + mod x11; #[cfg(feature = "wayland-data-control")] mod wayland; fn into_unknown(error: E) -> Error { - Error::Unknown { description: format!("{}", error) } + Error::Unknown { description: error.to_string() } } #[cfg(feature = "image-data")] @@ -31,13 +41,53 @@ fn encode_as_png(image: &ImageData) -> Result, Error> { image.bytes.as_ref(), image.width as u32, image.height as u32, - image::ColorType::Rgba8, + image::ExtendedColorType::Rgba8, ) .map_err(|_| Error::ConversionFailure)?; Ok(png_bytes) } +fn paths_from_uri_list(uri_list: Vec) -> Vec { + uri_list + .split(|char| *char == b'\n') + .filter_map(|line| line.strip_prefix(b"file://")) + .filter_map(|s| percent_decode(s).decode_utf8().ok()) + .map(|decoded| PathBuf::from(decoded.as_ref())) + .collect() +} + +fn paths_to_uri_list(file_list: &[impl AsRef]) -> Result { + // The characters that require encoding, which includes £ and € but they can't be added to the set. + const ASCII_SET: &AsciiSet = &CONTROLS + .add(b'#') + .add(b';') + .add(b'?') + .add(b'[') + .add(b']') + .add(b' ') + .add(b'\"') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'\\') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + + file_list + .iter() + .filter_map(|path| { + path.as_ref().canonicalize().ok().map(|path| { + format!("file://{}", percent_encode(path.as_os_str().as_bytes(), ASCII_SET)) + }) + }) + .reduce(|uri_list, uri| uri_list + "\n" + &uri) + .ok_or(Error::ConversionFailure) +} + /// Clipboard selection /// /// Linux has a concept of clipboard "selections" which tend to be used in different contexts. This @@ -122,6 +172,22 @@ impl<'clipboard> Get<'clipboard> { Clipboard::WlDataControl(clipboard) => clipboard.get_image(self.selection), } } + + pub(crate) fn html(self) -> Result { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_html(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_html(self.selection), + } + } + + pub(crate) fn file_list(self) -> Result, Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.get_file_list(self.selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.get_file_list(self.selection), + } + } } /// Linux-specific extensions to the [`Get`](super::Get) builder. @@ -140,39 +206,93 @@ impl GetExtLinux for crate::Get<'_> { } } +/// Configuration on how long to wait for a new X11 copy event is emitted. +#[derive(Default)] +pub(crate) enum WaitConfig { + /// Waits until the given [`Instant`] has reached. + Until(Instant), + + /// Waits forever until a new event is reached. + Forever, + + /// It shouldn't wait. + #[default] + None, +} + pub(crate) struct Set<'clipboard> { clipboard: &'clipboard mut Clipboard, - wait: bool, + wait: WaitConfig, selection: LinuxClipboardKind, + exclude_from_history: bool, } impl<'clipboard> Set<'clipboard> { pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { - Self { clipboard, wait: false, selection: LinuxClipboardKind::Clipboard } + Self { + clipboard, + wait: WaitConfig::default(), + selection: LinuxClipboardKind::Clipboard, + exclude_from_history: false, + } } pub(crate) fn text(self, text: Cow<'_, str>) -> Result<(), Error> { match self.clipboard { - Clipboard::X11(clipboard) => clipboard.set_text(text, self.selection, self.wait), + Clipboard::X11(clipboard) => { + clipboard.set_text(text, self.selection, self.wait, self.exclude_from_history) + } + #[cfg(feature = "wayland-data-control")] - Clipboard::WlDataControl(clipboard) => clipboard.set_text(text, self.selection, self.wait), + Clipboard::WlDataControl(clipboard) => { + clipboard.set_text(text, self.selection, self.wait, self.exclude_from_history) + } } } pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { match self.clipboard { - Clipboard::X11(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + Clipboard::X11(clipboard) => { + clipboard.set_html(html, alt, self.selection, self.wait, self.exclude_from_history) + } + #[cfg(feature = "wayland-data-control")] - Clipboard::WlDataControl(clipboard) => clipboard.set_html(html, alt, self.selection, self.wait), + Clipboard::WlDataControl(clipboard) => { + clipboard.set_html(html, alt, self.selection, self.wait, self.exclude_from_history) + } } } #[cfg(feature = "image-data")] pub(crate) fn image(self, image: ImageData<'_>) -> Result<(), Error> { match self.clipboard { - Clipboard::X11(clipboard) => clipboard.set_image(image, self.selection, self.wait), + Clipboard::X11(clipboard) => { + clipboard.set_image(image, self.selection, self.wait, self.exclude_from_history) + } + #[cfg(feature = "wayland-data-control")] - Clipboard::WlDataControl(clipboard) => clipboard.set_image(image, self.selection, self.wait), + Clipboard::WlDataControl(clipboard) => { + clipboard.set_image(image, self.selection, self.wait, self.exclude_from_history) + } + } + } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.set_file_list( + file_list, + self.selection, + self.wait, + self.exclude_from_history, + ), + + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.set_file_list( + file_list, + self.selection, + self.wait, + self.exclude_from_history, + ), } } } @@ -206,6 +326,16 @@ pub trait SetExtLinux: private::Sealed { /// [daemonize example]: https://github.com/1Password/arboard/blob/master/examples/daemonize.rs fn wait(self) -> Self; + /// Whether or not to wait for the clipboard's content to be replaced after setting it. This waits until the + /// `deadline` has exceeded. + /// + /// This is useful for short-lived programs so it won't block until new contents on the clipboard + /// were added. + /// + /// Note: this is a superset of [`wait()`][SetExtLinux::wait] and will overwrite any state + /// that was previously set using it. + fn wait_until(self, deadline: Instant) -> Self; + /// Sets the clipboard the operation will store its data to. /// /// If wayland support is enabled and available, attempting to use the Secondary clipboard will @@ -227,11 +357,18 @@ pub trait SetExtLinux: private::Sealed { /// # } /// ``` fn clipboard(self, selection: LinuxClipboardKind) -> Self; + + /// Excludes the data which will be set on the clipboard from being added to + /// the desktop clipboard managers' histories by adding the MIME-Type `x-kde-passwordMangagerHint` + /// to the clipboard's selection data. + /// + /// This is the most widely adopted convention on Linux. + fn exclude_from_history(self) -> Self; } impl SetExtLinux for crate::Set<'_> { fn wait(mut self) -> Self { - self.platform.wait = true; + self.platform.wait = WaitConfig::Forever; self } @@ -239,6 +376,16 @@ impl SetExtLinux for crate::Set<'_> { self.platform.selection = selection; self } + + fn wait_until(mut self, deadline: Instant) -> Self { + self.platform.wait = WaitConfig::Until(deadline); + self + } + + fn exclude_from_history(mut self) -> Self { + self.platform.exclude_from_history = true; + self + } } pub(crate) struct Clear<'clipboard> { @@ -255,10 +402,11 @@ impl<'clipboard> Clear<'clipboard> { } fn clear_inner(self, selection: LinuxClipboardKind) -> Result<(), Error> { - let mut set = Set::new(self.clipboard); - set.selection = selection; - - set.text(Cow::Borrowed("")) + match self.clipboard { + Clipboard::X11(clipboard) => clipboard.clear(selection), + #[cfg(feature = "wayland-data-control")] + Clipboard::WlDataControl(clipboard) => clipboard.clear(selection), + } } } @@ -269,7 +417,7 @@ pub trait ClearExtLinux: private::Sealed { /// ### Example /// /// ```no_run - /// # use arboard::{ Clipboard, LinuxClipboardKind, ClearExtLinux, Error }; + /// # use arboard::{Clipboard, LinuxClipboardKind, ClearExtLinux, Error}; /// # fn main() -> Result<(), Error> { /// let mut clipboard = Clipboard::new()?; /// @@ -290,3 +438,28 @@ impl ClearExtLinux for crate::Clear<'_> { self.platform.clear_inner(selection) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decoding_uri_list() { + // Test that paths_from_uri_list correctly decodes + // differents percent encoded characters + let file_list = [ + "file:///tmp/bar.log", + "file:///tmp/test%5C.txt", + "file:///tmp/foo%3F.png", + "file:///tmp/white%20space.txt", + ]; + + let paths = vec![ + PathBuf::from("/tmp/bar.log"), + PathBuf::from("/tmp/test\\.txt"), + PathBuf::from("/tmp/foo?.png"), + PathBuf::from("/tmp/white space.txt"), + ]; + assert_eq!(paths_from_uri_list(file_list.join("\n").into()), paths); + } +} diff --git a/src/platform/linux/wayland.rs b/src/platform/linux/wayland.rs index 955a736..eb4dfe5 100644 --- a/src/platform/linux/wayland.rs +++ b/src/platform/linux/wayland.rs @@ -1,6 +1,8 @@ -use std::borrow::Cow; -use std::convert::TryInto; -use std::io::Read; +use std::{ + borrow::Cow, + io::Read, + path::{Path, PathBuf}, +}; use wl_clipboard_rs::{ copy::{self, Error as CopyError, MimeSource, MimeType, Options, Source}, @@ -10,7 +12,10 @@ use wl_clipboard_rs::{ #[cfg(feature = "image-data")] use super::encode_as_png; -use super::{into_unknown, LinuxClipboardKind}; +use super::{ + into_unknown, paths_from_uri_list, paths_to_uri_list, LinuxClipboardKind, WaitConfig, + KDE_EXCLUSION_HINT, KDE_EXCLUSION_MIME, +}; use crate::common::Error; #[cfg(feature = "image-data")] use crate::common::ImageData; @@ -18,6 +23,8 @@ use crate::common::ImageData; #[cfg(feature = "image-data")] const MIME_PNG: &str = "image/png"; +const MIME_URI: &str = "text/uri-list"; + pub(crate) struct Clipboard {} impl TryInto for LinuxClipboardKind { @@ -44,52 +51,97 @@ impl TryInto for LinuxClipboardKind { } } +fn add_clipboard_exclusions(exclude_from_history: bool, sources: &mut Vec) { + if exclude_from_history { + sources.push(MimeSource { + source: Source::Bytes(Box::from(KDE_EXCLUSION_HINT)), + mime_type: MimeType::Specific(String::from(KDE_EXCLUSION_MIME)), + }); + } +} + +fn handle_copy_error(e: copy::Error) -> Error { + match e { + CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown(other), + } +} + +fn handle_paste_error(e: paste::Error) -> Error { + match e { + PasteError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, + other => into_unknown(other), + } +} + +fn handle_clipboard_read) -> Result>( + selection: LinuxClipboardKind, + mime: paste::MimeType, + into_requested_data: F, +) -> Result { + let result = get_contents(selection.try_into()?, Seat::Unspecified, mime); + match result { + Ok((mut pipe, _)) => { + let mut buffer = vec![]; + pipe.read_to_end(&mut buffer).map_err(into_unknown)?; + into_requested_data(buffer) + } + Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { + Err(Error::ContentNotAvailable) + } + Err(err) => Err(handle_paste_error(err)), + } +} + impl Clipboard { - #[allow(clippy::unnecessary_wraps)] pub(crate) fn new() -> Result { // Check if it's possible to communicate with the wayland compositor - if let Err(e) = is_primary_selection_supported() { - return Err(into_unknown(e)); + match is_primary_selection_supported() { + // We don't care if the primary clipboard is supported or not, `wl-clipboard-rs` will fail + // if not and we don't want to duplicate more of their logic. + Ok(_) => Ok(Self {}), + Err(e) => Err(into_unknown(e)), } - Ok(Self {}) + } + + pub(crate) fn clear(&mut self, selection: LinuxClipboardKind) -> Result<(), Error> { + let selection = selection.try_into()?; + copy::clear(selection, copy::Seat::All).map_err(handle_copy_error) } pub(crate) fn get_text(&mut self, selection: LinuxClipboardKind) -> Result { - use wl_clipboard_rs::paste::MimeType; - - let result = get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Text); - match result { - Ok((mut pipe, _)) => { - let mut contents = vec![]; - pipe.read_to_end(&mut contents).map_err(into_unknown)?; - String::from_utf8(contents).map_err(|_| Error::ConversionFailure) - } - - Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { - Err(Error::ContentNotAvailable) - } - - Err(PasteError::PrimarySelectionUnsupported) => Err(Error::ClipboardNotSupported), - - Err(err) => Err(Error::Unknown { description: format!("{}", err) }), - } + handle_clipboard_read(selection, paste::MimeType::Text, |contents| { + String::from_utf8(contents).map_err(|_| Error::ConversionFailure) + }) } pub(crate) fn set_text( &self, text: Cow<'_, str>, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<(), Error> { let mut opts = Options::new(); - opts.foreground(wait); + opts.foreground(matches!(wait, WaitConfig::Forever)); opts.clipboard(selection.try_into()?); - let source = Source::Bytes(text.into_owned().into_bytes().into_boxed_slice()); - opts.copy(source, MimeType::Text).map_err(|e| match e { - CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, - other => into_unknown(other), - })?; - Ok(()) + + let mut sources = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + + sources.push(MimeSource { + source: Source::Bytes(text.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Text, + }); + + add_clipboard_exclusions(exclude_from_history, &mut sources); + + opts.copy_multi(sources).map_err(handle_copy_error) + } + + pub(crate) fn get_html(&mut self, selection: LinuxClipboardKind) -> Result { + handle_clipboard_read(selection, paste::MimeType::Specific("text/html"), |contents| { + String::from_utf8(contents).map_err(|_| Error::ConversionFailure) + }) } pub(crate) fn set_html( @@ -97,29 +149,36 @@ impl Clipboard { html: Cow<'_, str>, alt: Option>, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<(), Error> { - let html_mime = MimeType::Specific(String::from("text/html")); let mut opts = Options::new(); - opts.foreground(wait); + opts.foreground(matches!(wait, WaitConfig::Forever)); opts.clipboard(selection.try_into()?); - let html_source = Source::Bytes(html.into_owned().into_bytes().into_boxed_slice()); - match alt { - Some(alt_text) => { - let alt_source = - Source::Bytes(alt_text.into_owned().into_bytes().into_boxed_slice()); - opts.copy_multi(vec![ - MimeSource { source: alt_source, mime_type: MimeType::Text }, - MimeSource { source: html_source, mime_type: html_mime }, - ]) - } - None => opts.copy(html_source, html_mime), + + let mut sources = { + let cap = [true, alt.is_some(), exclude_from_history] + .map(|v| usize::from(v as u8)) + .iter() + .sum(); + Vec::with_capacity(cap) + }; + + if let Some(alt) = alt { + sources.push(MimeSource { + source: Source::Bytes(alt.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Text, + }); } - .map_err(|e| match e { - CopyError::PrimarySelectionUnsupported => Error::ClipboardNotSupported, - other => into_unknown(other), - })?; - Ok(()) + + sources.push(MimeSource { + source: Source::Bytes(html.into_owned().into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from("text/html")), + }); + + add_clipboard_exclusions(exclude_from_history, &mut sources); + + opts.copy_multi(sources).map_err(handle_copy_error) } #[cfg(feature = "image-data")] @@ -128,34 +187,21 @@ impl Clipboard { selection: LinuxClipboardKind, ) -> Result, Error> { use std::io::Cursor; - use wl_clipboard_rs::paste::MimeType; - let result = - get_contents(selection.try_into()?, Seat::Unspecified, MimeType::Specific(MIME_PNG)); - match result { - Ok((mut pipe, _mime_type)) => { - let mut buffer = vec![]; - pipe.read_to_end(&mut buffer).map_err(into_unknown)?; - let image = image::io::Reader::new(Cursor::new(buffer)) - .with_guessed_format() - .map_err(|_| Error::ConversionFailure)? - .decode() - .map_err(|_| Error::ConversionFailure)?; - let image = image.into_rgba8(); + handle_clipboard_read(selection, paste::MimeType::Specific(MIME_PNG), |buffer| { + let image = image::io::Reader::new(Cursor::new(buffer)) + .with_guessed_format() + .map_err(|_| Error::ConversionFailure)? + .decode() + .map_err(|_| Error::ConversionFailure)?; + let image = image.into_rgba8(); - Ok(ImageData { - width: image.width() as usize, - height: image.height() as usize, - bytes: image.into_raw().into(), - }) - } - - Err(PasteError::ClipboardEmpty) | Err(PasteError::NoMimeType) => { - Err(Error::ContentNotAvailable) - } - - Err(err) => Err(Error::Unknown { description: format!("{}", err) }), - } + Ok(ImageData { + width: image.width() as usize, + height: image.height() as usize, + bytes: image.into_raw().into(), + }) + }) } #[cfg(feature = "image-data")] @@ -163,14 +209,57 @@ impl Clipboard { &mut self, image: ImageData, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<(), Error> { - let image = encode_as_png(&image)?; let mut opts = Options::new(); - opts.foreground(wait); + opts.foreground(matches!(wait, WaitConfig::Forever)); opts.clipboard(selection.try_into()?); - let source = Source::Bytes(image.into()); - opts.copy(source, MimeType::Specific(MIME_PNG.into())).map_err(into_unknown)?; - Ok(()) + + let image = encode_as_png(&image)?; + + let mut sources = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + + sources.push(MimeSource { + source: Source::Bytes(image.into()), + mime_type: MimeType::Specific(String::from(MIME_PNG)), + }); + + add_clipboard_exclusions(exclude_from_history, &mut sources); + + opts.copy_multi(sources).map_err(handle_copy_error) + } + + pub(crate) fn get_file_list( + &mut self, + selection: LinuxClipboardKind, + ) -> Result, Error> { + handle_clipboard_read(selection, paste::MimeType::Specific(MIME_URI), |contents| { + Ok(paths_from_uri_list(contents)) + }) + } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + exclude_from_history: bool, + ) -> Result<(), Error> { + let files = paths_to_uri_list(file_list)?; + + let mut opts = Options::new(); + opts.foreground(matches!(wait, WaitConfig::Forever)); + opts.clipboard(selection.try_into()?); + + let mut sources = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + sources.push(MimeSource { + source: Source::Bytes(files.into_bytes().into_boxed_slice()), + mime_type: MimeType::Specific(String::from(MIME_URI)), + }); + + add_clipboard_exclusions(exclude_from_history, &mut sources); + + opts.copy_multi(sources).map_err(handle_copy_error) } } diff --git a/src/platform/linux/x11.rs b/src/platform/linux/x11.rs index 21f79a0..17f107b 100644 --- a/src/platform/linux/x11.rs +++ b/src/platform/linux/x11.rs @@ -16,14 +16,14 @@ use std::{ borrow::Cow, cell::RefCell, collections::{hash_map::Entry, HashMap}, + path::{Path, PathBuf}, sync::{ atomic::{AtomicBool, Ordering}, - mpsc, Arc, + Arc, }, - thread::{self, JoinHandle}, + thread::JoinHandle, thread_local, time::{Duration, Instant}, - usize, }; use log::{error, trace, warn}; @@ -45,7 +45,10 @@ use x11rb::{ #[cfg(feature = "image-data")] use super::encode_as_png; -use super::{into_unknown, LinuxClipboardKind}; +use super::{ + into_unknown, paths_from_uri_list, paths_to_uri_list, LinuxClipboardKind, WaitConfig, + KDE_EXCLUSION_HINT, KDE_EXCLUSION_MIME, +}; #[cfg(feature = "image-data")] use crate::ImageData; use crate::{common::ScopeGuard, Error}; @@ -78,8 +81,10 @@ x11rb::atom_manager! { TEXT_MIME_UNKNOWN: b"text/plain", HTML: b"text/html", + URI_LIST: b"text/uri-list", PNG_MIME: b"image/png", + X_KDE_PASSWORDMANAGERHINT: KDE_EXCLUSION_MIME.as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. @@ -134,19 +139,11 @@ struct Inner { impl XContext { fn new() -> Result { // create a new connection to an X11 server - // with a timeout on connecting to the socket in case of hangage - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - tx.send(RustConnection::connect(None)).ok(); // disregard error sending on channel as main thread has timed out. - }); - let patient_conn = rx.recv_timeout(SHORT_TIMEOUT_DUR).map_err(into_unknown)?; - let (conn, screen_num): (RustConnection, _) = patient_conn.map_err(into_unknown)?; - - let screen = conn - .setup() - .roots - .get(screen_num) - .ok_or(Error::Unknown { description: String::from("no screen found") })?; + let (conn, screen_num): (RustConnection, _) = + RustConnection::connect(None).map_err(|_| { + Error::unknown("X11 server connection timed out because it was unreachable") + })?; + let screen = conn.setup().roots.get(screen_num).ok_or(Error::unknown("no screen found"))?; let win_id = conn.generate_id().map_err(into_unknown)?; let event_mask = @@ -181,8 +178,9 @@ impl XContext { #[derive(Default)] struct Selection { data: RwLock>>, - /// Mutex around nothing to use with the below condvar. - mutex: Mutex<()>, + /// Mutex around when this selection was last changed by us + /// for both use with the below condvar and logging. + mutex: Mutex>, /// A condvar that is notified when the contents of this clipboard are changed. /// /// This is associated with `Self::mutex`. @@ -221,48 +219,72 @@ impl Inner { }) } + /// Performs a "clear" operation on the clipboard, which is implemented by + /// relinquishing the selection to revert its owner to `None`. This gracefully + /// and comformly informs the X server and any clipboard managers that the + /// data was no longer valid and won't be offered from our window anymore. + /// + /// See `ask_clipboard_manager_to_request_our_data` for more details on why + /// this is important and specification references. + fn clear(&self, selection: LinuxClipboardKind) -> Result<()> { + let selection = self.atom_of(selection); + + self.server + .conn + .set_selection_owner(NONE, selection, Time::CURRENT_TIME) + .map_err(into_unknown)?; + + self.server.conn.flush().map_err(into_unknown) + } + fn write( &self, data: Vec, - selection: LinuxClipboardKind, - wait: bool, + clipboard_selection: LinuxClipboardKind, + wait: WaitConfig, ) -> Result<()> { if self.serve_stopped.load(Ordering::Relaxed) { - return Err(Error::Unknown { - description: "The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)".into() - }); + return Err(Error::unknown("The clipboard handler thread seems to have stopped. Logging messages may reveal the cause. (See the `log` crate.)")); } let server_win = self.server.win_id; + // Just setting the data, and the `serve_requests` will take care of the rest. + let selection = self.selection_of(clipboard_selection); + let mut data_guard = selection.data.write(); + *data_guard = Some(data); + // ICCCM version 2, section 2.6.1.3 states that we should re-assert ownership whenever data // changes. self.server .conn - .set_selection_owner(server_win, self.atom_of(selection), Time::CURRENT_TIME) + .set_selection_owner(server_win, self.atom_of(clipboard_selection), Time::CURRENT_TIME) .map_err(|_| Error::ClipboardOccupied)?; self.server.conn.flush().map_err(into_unknown)?; - // Just setting the data, and the `serve_requests` will take care of the rest. - let selection = self.selection_of(selection); - let mut data_guard = selection.data.write(); - *data_guard = Some(data); - // Lock the mutex to both ensure that no wakers of `data_changed` can wake us between // dropping the `data_guard` and calling `wait[_for]` and that we don't we wake other // threads in that position. let mut guard = selection.mutex.lock(); + // Record the time we modify the selection. + *guard = Some(Instant::now()); // Notify any existing waiting threads that we have changed the data in the selection. // It is important that the mutex is locked to prevent this notification getting lost. selection.data_changed.notify_all(); - if wait { - drop(data_guard); + match wait { + WaitConfig::None => {} + WaitConfig::Forever => { + drop(data_guard); + selection.data_changed.wait(&mut guard); + } - // Wait for the clipboard's content to be changed. - selection.data_changed.wait(&mut guard); + WaitConfig::Until(deadline) => { + drop(data_guard); + selection.data_changed.wait_until(&mut guard, deadline); + } } Ok(()) @@ -524,9 +546,7 @@ impl Inner { Ok(ReadSelNotifyResult::IncrStarted) } else { // this should never happen, we have sent a request only for supported types - Err(Error::Unknown { - description: String::from("incorrect type received from clipboard"), - }) + Err(Error::unknown("incorrect type received from clipboard")) } } @@ -582,11 +602,13 @@ impl Inner { // we are asked for a list of supported conversion targets if event.target == self.atoms.TARGETS { trace!("Handling TARGETS, dst property is {}", self.atom_name_dbg(event.property)); - let mut targets = Vec::with_capacity(10); - targets.push(self.atoms.TARGETS); - targets.push(self.atoms.SAVE_TARGETS); + let data = self.selection_of(selection).data.read(); - if let Some(data_list) = &*data { + let (data_targets, excluded) = if let Some(data_list) = &*data { + // Estimation based on current data types, plus the other UTF-8 ones, plus `SAVE_TARGETS`. + let mut targets = Vec::with_capacity(data_list.len() + 3); + let mut excluded = false; + for data in data_list { targets.push(data.format); if data.format == self.atoms.UTF8_STRING { @@ -595,8 +617,32 @@ impl Inner { targets.push(self.atoms.UTF8_MIME_0); targets.push(self.atoms.UTF8_MIME_1); } + + if data.format == self.atoms.X_KDE_PASSWORDMANAGERHINT { + excluded = true; + } } + (targets, excluded) + } else { + // If there's no data, we advertise an empty list of targets. + (Vec::with_capacity(2), false) + }; + + let mut targets = data_targets; + targets.push(self.atoms.TARGETS); + + // NB: `SAVE_TARGETS` in this context is a marker atom which infomrs the clipboard manager + // we support this operation and _may_ use it in the future. To try and keep the manager's + // expectations/assumptions (if any) about when we will invoke this handoff, we go ahead and + // skip advertising support for the save operation entirely when the data was marked as + // sensitive. + // + // Note that even if we don't advertise it, some managers may respond to it anyways so this is + // only half of exclusion handling. See `ask_clipboard_manager_to_request_our_data` for more. + if !excluded { + targets.push(self.atoms.SAVE_TARGETS); } + self.server .conn .change_property32( @@ -669,13 +715,48 @@ impl Inner { return Ok(()); } - if !self.is_owner(LinuxClipboardKind::Clipboard)? { + // Per the `ClipboardManager` specification, only the `CLIPBOARD` target is + // to be saved from other X clients, so if the caller set the `Primary` (or `Secondary`) clipboard, + // we wouldn't expect any clipboard manager to save that anyway. + let selection = LinuxClipboardKind::Clipboard; + + if !self.is_owner(selection)? { // We are not owning the clipboard, nothing to do. return Ok(()); } - if self.selection_of(LinuxClipboardKind::Clipboard).data.read().is_none() { - // If we don't have any data, there's nothing to do. - return Ok(()); + + match &*self.selection_of(selection).data.read() { + Some(data) => { + // If the data we are serving intended to be excluded, then don't bother asking the clipboard + // manager to save it. This is for several reasons: + // 1. Its counter-intuitive because the caller asked for this data to be minimally retained. + // 2. Regardless of if `SAVE_TARGETS` was advertised, we have to assume the manager may be saving history + // in a more proactive way and that would also be entirely dependent on it seeing the exclusion MIME before this. + // 3. Due to varying behavior in clipboard managers (some save prior to `SAVE_TARGETS`), it may just + // generate unnessecary warning logs in our handoff path even when we know a well-behaving manager isn't + // trying to save our sensitive data and that is misleading to users. + if data.iter().any(|data| data.format == self.atoms.X_KDE_PASSWORDMANAGERHINT) { + // This step is the most important. Without it, some clipboard managers may think that our process + // crashed since the X window is destroyed without changing the selection owner first and try to save data. + // + // While this shouldn't need to happen based only on ICCCM 2.3.1 ("Voluntarily Giving Up Selection Ownership"), + // its documentation that destorying the owner window or terminating also reverts the owner to `None` doesn't + // reflect how desktop environment's X servers work in reality. + // + // By removing the owner, the manager doesn't think it needs to pick up our window's data serving once + // its destroyed and cleanly lets the data disappear based off the previously advertised exclusion hint. + if let Err(e) = self.clear(selection) { + warn!("failed to release sensitive data's clipboard ownership: {e}; it may end up persisted!"); + // This is still not an error because we werent going to handoff anything to the manager. + } + + return Ok(()); + } + } + None => { + // If we don't have any data, there's nothing to do. + return Ok(()); + } } // It's important that we lock the state before sending the request @@ -711,9 +792,7 @@ impl Inner { return Ok(()); } - Err(Error::Unknown { - description: "The handover was not finished and the condvar didn't time out, yet the condvar wait ended. This should be unreachable.".into() - }) + unreachable!("This is a bug! The handover was not finished and the condvar didn't time out, yet the condvar wait ended.") } } @@ -722,7 +801,7 @@ fn serve_requests(context: Arc) -> Result<(), Box> log::trace!("Finishing clipboard manager handover."); *handover_state = ManagerHandoverState::Finished; - // Not sure if unlocking the mutext is necessary here but better safe than sorry. + // Not sure if unlocking the mutex is necessary here but better safe than sorry. drop(handover_state); clip.handover_cv.notify_all(); @@ -771,7 +850,10 @@ fn serve_requests(context: Arc) -> Result<(), Box> context.atom_name_dbg(event.target), ); // Someone is requesting the clipboard content from us. - context.handle_selection_request(event).map_err(into_unknown)?; + if let Err(e) = context.handle_selection_request(event) { + error!("Failed to handle selection request: {e}"); + continue; + } // if we are in the progress of saving to the clipboard manager // make sure we save that we have finished writing @@ -850,6 +932,19 @@ impl Clipboard { Ok(Self { inner: ctx }) } + fn add_clipboard_exclusions(&self, exclude_from_history: bool, data: &mut Vec) { + if exclude_from_history { + data.push(ClipboardData { + bytes: KDE_EXCLUSION_HINT.to_vec(), + format: self.inner.atoms.X_KDE_PASSWORDMANAGERHINT, + }) + } + } + + pub(crate) fn clear(&self, selection: LinuxClipboardKind) -> Result<()> { + self.inner.clear(selection) + } + pub(crate) fn get_text(&self, selection: LinuxClipboardKind) -> Result { let formats = [ self.inner.atoms.UTF8_STRING, @@ -873,23 +968,42 @@ impl Clipboard { &self, message: Cow<'_, str>, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<()> { - let data = vec![ClipboardData { + let mut data = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + data.push(ClipboardData { bytes: message.into_owned().into_bytes(), format: self.inner.atoms.UTF8_STRING, - }]; + }); + + self.add_clipboard_exclusions(exclude_from_history, &mut data); + self.inner.write(data, selection, wait) } + pub(crate) fn get_html(&self, selection: LinuxClipboardKind) -> Result { + let formats = [self.inner.atoms.HTML]; + let result = self.inner.read(&formats, selection)?; + String::from_utf8(result.bytes).map_err(|_| Error::ConversionFailure) + } + pub(crate) fn set_html( &self, html: Cow<'_, str>, alt: Option>, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<()> { - let mut data = vec![]; + let mut data = { + let cap = [true, alt.is_some(), exclude_from_history] + .map(|v| usize::from(v as u8)) + .iter() + .sum(); + Vec::with_capacity(cap) + }; + if let Some(alt_text) = alt { data.push(ClipboardData { bytes: alt_text.into_owned().into_bytes(), @@ -900,6 +1014,9 @@ impl Clipboard { bytes: html.into_owned().into_bytes(), format: self.inner.atoms.HTML, }); + + self.add_clipboard_exclusions(exclude_from_history, &mut data); + self.inner.write(data, selection, wait) } @@ -926,10 +1043,38 @@ impl Clipboard { &self, image: ImageData, selection: LinuxClipboardKind, - wait: bool, + wait: WaitConfig, + exclude_from_history: bool, ) -> Result<()> { let encoded = encode_as_png(&image)?; - let data = vec![ClipboardData { bytes: encoded, format: self.inner.atoms.PNG_MIME }]; + let mut data = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + + data.push(ClipboardData { bytes: encoded, format: self.inner.atoms.PNG_MIME }); + + self.add_clipboard_exclusions(exclude_from_history, &mut data); + + self.inner.write(data, selection, wait) + } + + pub(crate) fn get_file_list(&self, selection: LinuxClipboardKind) -> Result> { + let result = self.inner.read(&[self.inner.atoms.URI_LIST], selection)?; + + Ok(paths_from_uri_list(result.bytes)) + } + + pub(crate) fn set_file_list( + &self, + file_list: &[impl AsRef], + selection: LinuxClipboardKind, + wait: WaitConfig, + exclude_from_history: bool, + ) -> Result<()> { + let files = paths_to_uri_list(file_list)?; + let mut data = Vec::with_capacity(if exclude_from_history { 2 } else { 1 }); + + data.push(ClipboardData { bytes: files.into_bytes(), format: self.inner.atoms.URI_LIST }); + self.add_clipboard_exclusions(exclude_from_history, &mut data); + self.inner.write(data, selection, wait) } } @@ -961,7 +1106,10 @@ impl Drop for Clipboard { return; } if let Some(global_cb) = global_cb { - if let Err(e) = global_cb.server_handle.join() { + let GlobalClipboard { inner, server_handle } = global_cb; + drop(inner); + + if let Err(e) = server_handle.join() { // Let's try extracting the error message let message; if let Some(msg) = e.downcast_ref::<&'static str>() { @@ -980,6 +1128,49 @@ impl Drop for Clipboard { error!("The clipboard server thread panicked."); } } + + // By this point we've dropped the Global's reference to `Inner` and the background + // thread has exited which means it also dropped its reference. Therefore `self.inner` should + // be the last strong count. + // + // Note: The following is all best effort and is only for logging. Nothing is guaranteed to execute + // or log. + #[cfg(debug_assertions)] + if let Some(inner) = Arc::get_mut(&mut self.inner) { + use std::io::IsTerminal; + + let mut change_timestamps = Vec::with_capacity(2); + let mut collect_changed = |sel: &mut Mutex>| { + if let Some(changed) = sel.get_mut() { + change_timestamps.push(*changed); + } + }; + + collect_changed(&mut inner.clipboard.mutex); + collect_changed(&mut inner.primary.mutex); + collect_changed(&mut inner.secondary.mutex); + + change_timestamps.sort(); + if let Some(last) = change_timestamps.last() { + let elapsed = last.elapsed().as_millis(); + // This number has no meaning, its just a guess for how long + // might be reasonable to give a clipboard manager a chance to + // save contents based ~roughly on the handoff timeout. + if elapsed > 100 { + return; + } + + // If the app isn't running in a terminal don't print, use log instead. + // Printing has a higher chance of being seen though, so its our default. + // Its also close enough to a `debug_assert!` that it shouldn't come across strange. + let msg = format!("Clipboard was dropped very quickly after writing ({elapsed}ms); clipboard managers may not have seen the contents\nConsider keeping `Clipboard` in more persistent state somewhere or keeping the contents alive longer using `SetLinuxExt` and/or threads."); + if std::io::stderr().is_terminal() { + eprintln!("{msg}"); + } else { + log::warn!("{msg}"); + } + } + } } } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index b336463..268eb47 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -14,4 +14,4 @@ pub use windows::*; #[cfg(target_os = "macos")] mod osx; #[cfg(target_os = "macos")] -pub(crate) use osx::*; +pub use osx::*; diff --git a/src/platform/osx.rs b/src/platform/osx.rs index cc7a4e7..31952b6 100644 --- a/src/platform/osx.rs +++ b/src/platform/osx.rs @@ -8,34 +8,25 @@ the Apache 2.0 or the MIT license at the licensee's choice. The terms and conditions of the chosen license apply to this file. */ -use crate::common::Error; #[cfg(feature = "image-data")] use crate::common::ImageData; -#[cfg(feature = "image-data")] -use core_graphics::{ - base::{kCGBitmapByteOrderDefault, kCGImageAlphaLast, kCGRenderingIntentDefault, CGFloat}, - color_space::CGColorSpace, - data_provider::{CGDataProvider, CustomData}, - image::CGImage, -}; -use objc::{ +use crate::common::{private, Error}; +use objc2::{ msg_send, - rc::autoreleasepool, - runtime::{Class, Object}, - sel, sel_impl, + rc::{autoreleasepool, Retained}, + runtime::ProtocolObject, + ClassType, +}; +use objc2_app_kit::{ + NSPasteboard, NSPasteboardTypeHTML, NSPasteboardTypeString, + NSPasteboardURLReadingFileURLsOnlyKey, +}; +use objc2_foundation::{ns_string, NSArray, NSDictionary, NSNumber, NSString, NSURL}; +use std::{ + borrow::Cow, + panic::{RefUnwindSafe, UnwindSafe}, + path::{Path, PathBuf}, }; -use objc_foundation::{INSArray, INSFastEnumeration, INSString, NSArray, NSObject, NSString}; -use objc_id::{Id, Owned}; -use std::{borrow::Cow, ptr::NonNull}; - -// Required to bring NSPasteboard into the path of the class-resolver -#[link(name = "AppKit", kind = "framework")] -extern "C" { - static NSPasteboardTypeHTML: *const Object; - static NSPasteboardTypeString: *const Object; - #[cfg(feature = "image-data")] - static NSPasteboardTypeTIFF: *const Object; -} /// Returns an NSImage object on success. #[cfg(feature = "image-data")] @@ -43,80 +34,122 @@ fn image_from_pixels( pixels: Vec, width: usize, height: usize, -) -> Result, Box> { - #[repr(C)] - #[derive(Copy, Clone)] - struct NSSize { - width: CGFloat, - height: CGFloat, +) -> Result, Error> { + use objc2::AllocAnyThread; + use objc2_app_kit::NSImage; + use objc2_core_foundation::CGFloat; + use objc2_core_graphics::{ + CGBitmapInfo, CGColorRenderingIntent, CGColorSpaceCreateDeviceRGB, + CGDataProviderCreateWithData, CGImageAlphaInfo, CGImageCreate, + }; + use objc2_foundation::NSSize; + use std::{ + ffi::c_void, + ptr::{self, NonNull}, + }; + + unsafe extern "C-unwind" fn release(_info: *mut c_void, data: NonNull, size: usize) { + let data = data.cast::(); + let slice = NonNull::slice_from_raw_parts(data, size); + // SAFETY: This is the same slice that we got from `Box::into_raw`. + drop(unsafe { Box::from_raw(slice.as_ptr()) }) } - #[derive(Debug)] - struct PixelArray { - data: Vec, + let provider = { + let pixels = pixels.into_boxed_slice(); + let len = pixels.len(); + let pixels: *mut [u8] = Box::into_raw(pixels); + // Convert slice pointer to thin pointer. + let data_ptr = pixels.cast::(); + + // SAFETY: The data pointer and length are valid. + // The info pointer can safely be NULL, we don't use it in the `release` callback. + unsafe { CGDataProviderCreateWithData(ptr::null_mut(), data_ptr, len, Some(release)) } } + .unwrap(); - impl CustomData for PixelArray { - unsafe fn ptr(&self) -> *const u8 { - self.data.as_ptr() - } - unsafe fn len(&self) -> usize { - self.data.len() - } + let colorspace = unsafe { CGColorSpaceCreateDeviceRGB() }.unwrap(); + + // XXX: If this returns an error, try running your application from the command line or + // use `Console.app`. For the later, make sure that before you start streaming log messages + // that Action -> `Include Info Messages` and Action -> `Include Debug Messages` are both + // enabled in the menubar. CoreGraphics will write debugging/error information to these places. + // + // - https://redsweater.com/blog/129/coregraphics-log-jam + // - https://github.com/1Password/arboard/issues/204 + let cg_image = unsafe { + CGImageCreate( + width, + height, + 8, + 32, + 4 * width, + Some(&colorspace), + CGBitmapInfo::ByteOrderDefault | CGBitmapInfo(CGImageAlphaInfo::Last.0), + Some(&provider), + ptr::null_mut(), + false, + CGColorRenderingIntent::RenderingIntentDefault, + ) } + .ok_or(Error::ConversionFailure)?; - let colorspace = CGColorSpace::create_device_rgb(); - let pixel_data: Box> = Box::new(Box::new(PixelArray { data: pixels })); - let provider = unsafe { CGDataProvider::from_custom_data(pixel_data) }; - - let cg_image = CGImage::new( - width, - height, - 8, - 32, - 4 * width, - &colorspace, - kCGBitmapByteOrderDefault | kCGImageAlphaLast, - &provider, - false, - kCGRenderingIntentDefault, - ); let size = NSSize { width: width as CGFloat, height: height as CGFloat }; - let nsimage_class = objc::class!(NSImage); - // Take ownership of the newly allocated object, which has an existing retain count. - let image: Id = unsafe { Id::from_retained_ptr(msg_send![nsimage_class, alloc]) }; - #[allow(clippy::let_unit_value)] - { - // Note: `initWithCGImage` expects a reference (`CGImageRef`), not an actual object. - let _: () = unsafe { msg_send![image, initWithCGImage: &*cg_image size:size] }; - } - - Ok(image) + Ok(unsafe { NSImage::initWithCGImage_size(NSImage::alloc(), &cg_image, size) }) } pub(crate) struct Clipboard { - pasteboard: Id, + pasteboard: Retained, } +unsafe impl Send for Clipboard {} +unsafe impl Sync for Clipboard {} +impl UnwindSafe for Clipboard {} +impl RefUnwindSafe for Clipboard {} + impl Clipboard { pub(crate) fn new() -> Result { - let cls = Class::get("NSPasteboard").expect("NSPasteboard not registered"); - let pasteboard: *mut Object = unsafe { msg_send![cls, generalPasteboard] }; + // Rust only supports 10.7+, while `generalPasteboard` first appeared + // in 10.0, so this should always be available. + // + // However, in some edge cases, like running under launchd (in some + // modes) as a daemon, the clipboard object may be unavailable, and + // then `generalPasteboard` will return NULL even though it's + // documented not to. + // + // Otherwise we'd just use `NSPasteboard::generalPasteboard()` here. + let pasteboard: Option> = + unsafe { msg_send![NSPasteboard::class(), generalPasteboard] }; - if !pasteboard.is_null() { - // SAFETY: `generalPasteboard` is not null and a valid object pointer. - let pasteboard: Id = unsafe { Id::from_ptr(pasteboard) }; + if let Some(pasteboard) = pasteboard { Ok(Clipboard { pasteboard }) } else { - // Rust only supports 10.7+, while `generalPasteboard` first appeared in 10.0, so this - // is unreachable in "normal apps". However in some edge cases, like running under - // launchd (in some modes) as a daemon, the clipboard object may be unavailable. Err(Error::ClipboardNotSupported) } } fn clear(&mut self) { - let _: usize = unsafe { msg_send![self.pasteboard, clearContents] }; + unsafe { self.pasteboard.clearContents() }; + } + + fn string_from_type(&self, type_: &'static NSString) -> Result { + // XXX: There does not appear to be an alternative for obtaining text without the need for + // autorelease behavior. + autoreleasepool(|_| { + // XXX: We explicitly use `pasteboardItems` and not `stringForType` since the latter will concat + // multiple strings, if present, into one and return it instead of reading just the first which is `arboard`'s + // historical behavior. + let contents = unsafe { self.pasteboard.pasteboardItems() } + .ok_or_else(|| Error::unknown("NSPasteboard#pasteboardItems errored"))?; + + for item in contents { + if let Some(string) = unsafe { item.stringForType(type_) } { + return Ok(string.to_string()); + } + } + + Err(Error::ContentNotAvailable) + }) } // fn get_binary_contents(&mut self) -> Result, Box> { @@ -171,68 +204,35 @@ impl Clipboard { } pub(crate) struct Get<'clipboard> { - pasteboard: &'clipboard Object, + clipboard: &'clipboard Clipboard, } impl<'clipboard> Get<'clipboard> { pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { - Self { pasteboard: &*clipboard.pasteboard } + Self { clipboard } } pub(crate) fn text(self) -> Result { - // XXX: There does not appear to be an alternative for obtaining text without the need for - // autorelease behavior. - autoreleasepool(|| { - // XXX: We explicitly use `pasteboardItems` and not `stringForType` since the latter will concat - // multiple strings, if present, into one and return it instead of reading just the first which is `arboard`'s - // historical behavior. - let contents: Option>> = - unsafe { msg_send![self.pasteboard, pasteboardItems] }; + unsafe { self.clipboard.string_from_type(NSPasteboardTypeString) } + } - let contents = contents.map(|c| unsafe { c.as_ref() }).ok_or_else(|| { - Error::Unknown { description: String::from("NSPasteboard#pasteboardItems errored") } - })?; - - for item in contents.enumerator() { - let maybe_str: Option> = - unsafe { msg_send![item, stringForType:NSPasteboardTypeString] }; - - match maybe_str { - Some(string) => { - let string: Id = unsafe { Id::from_ptr(string.as_ptr()) }; - return Ok(string.as_str().to_owned()); - } - None => continue, - } - } - - Err(Error::ContentNotAvailable) - }) + pub(crate) fn html(self) -> Result { + unsafe { self.clipboard.string_from_type(NSPasteboardTypeHTML) } } #[cfg(feature = "image-data")] pub(crate) fn image(self) -> Result, Error> { - use objc_foundation::NSData; + use objc2_app_kit::NSPasteboardTypeTIFF; use std::io::Cursor; // XXX: There does not appear to be an alternative for obtaining images without the need for // autorelease behavior. - let image = autoreleasepool(|| { - let obj: Option> = - unsafe { msg_send![self.pasteboard, dataForType: NSPasteboardTypeTIFF] }; + let image = autoreleasepool(|_| { + let image_data = unsafe { self.clipboard.pasteboard.dataForType(NSPasteboardTypeTIFF) } + .ok_or(Error::ContentNotAvailable)?; - let image_data: Id = if let Some(obj) = obj { - unsafe { Id::from_ptr(obj.as_ptr()) } - } else { - return Err(Error::ContentNotAvailable); - }; - - let data = unsafe { - let len: usize = msg_send![&*image_data, length]; - let bytes: *const u8 = msg_send![&*image_data, bytes]; - - Cursor::new(std::slice::from_raw_parts(bytes, len)) - }; + // SAFETY: The data is not modified while in use here. + let data = Cursor::new(unsafe { image_data.as_bytes_unchecked() }); let reader = image::io::Reader::with_format(data, image::ImageFormat::Tiff); reader.decode().map_err(|_| Error::ConversionFailure) @@ -247,29 +247,61 @@ impl<'clipboard> Get<'clipboard> { bytes: rgba.into_raw().into(), }) } + + pub(crate) fn file_list(self) -> Result, Error> { + autoreleasepool(|_| { + let class_array = NSArray::from_slice(&[NSURL::class()]); + let options = NSDictionary::from_slices( + &[unsafe { NSPasteboardURLReadingFileURLsOnlyKey }], + &[NSNumber::new_bool(true).as_ref()], + ); + let objects = unsafe { + self.clipboard + .pasteboard + .readObjectsForClasses_options(&class_array, Some(&options)) + }; + + objects + .map(|array| { + array + .iter() + .filter_map(|obj| { + obj.downcast::().ok().and_then(|url| { + unsafe { url.path() }.map(|p| PathBuf::from(p.to_string())) + }) + }) + .collect::>() + }) + .filter(|file_list| !file_list.is_empty()) + .ok_or(Error::ContentNotAvailable) + }) + } } pub(crate) struct Set<'clipboard> { clipboard: &'clipboard mut Clipboard, + exclude_from_history: bool, } impl<'clipboard> Set<'clipboard> { pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { - Self { clipboard } + Self { clipboard, exclude_from_history: false } } pub(crate) fn text(self, data: Cow<'_, str>) -> Result<(), Error> { self.clipboard.clear(); - let string_array = NSArray::from_vec(vec![NSString::from_str(&data)]); - // Make sure that we pass a pointer to the system and not the array object itself. Otherwise, - // the system won't free it because the API doesn't give it ownership of the data. This results in - // a memory leak because Rust can never run its destructor. - let success = unsafe { msg_send![self.clipboard.pasteboard, writeObjects: &*string_array] }; + let string_array = NSArray::from_retained_slice(&[ProtocolObject::from_retained( + NSString::from_str(&data), + )]); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&string_array) }; + + add_clipboard_exclusions(self.clipboard, self.exclude_from_history); + if success { Ok(()) } else { - Err(Error::Unknown { description: "NSPasteboard#writeObjects: returned false".into() }) + Err(Error::unknown("NSPasteboard#writeObjects: returned false")) } } @@ -282,49 +314,80 @@ impl<'clipboard> Set<'clipboard> { // https://bugzilla.mozilla.org/show_bug.cgi?id=466599 // https://bugs.chromium.org/p/chromium/issues/detail?id=11957 let html = format!( - r#"{}"#, - html + r#"{html}"#, ); let html_nss = NSString::from_str(&html); // Make sure that we pass a pointer to the string and not the object itself. - let mut success: bool = unsafe { - msg_send![self.clipboard.pasteboard, setString: &*html_nss forType:NSPasteboardTypeHTML] - }; + let mut success = + unsafe { self.clipboard.pasteboard.setString_forType(&html_nss, NSPasteboardTypeHTML) }; if success { if let Some(alt_text) = alt { let alt_nss = NSString::from_str(&alt_text); // Similar to the primary string, we only want a pointer here too. success = unsafe { - msg_send![self.clipboard.pasteboard, setString: &*alt_nss forType:NSPasteboardTypeString] + self.clipboard.pasteboard.setString_forType(&alt_nss, NSPasteboardTypeString) }; } } + + add_clipboard_exclusions(self.clipboard, self.exclude_from_history); + if success { Ok(()) } else { - Err(Error::Unknown { description: "NSPasteboard#writeObjects: returned false".into() }) + Err(Error::unknown("NSPasteboard#writeObjects: returned false")) } } #[cfg(feature = "image-data")] pub(crate) fn image(self, data: ImageData) -> Result<(), Error> { let pixels = data.bytes.into(); - let image = image_from_pixels(pixels, data.width, data.height) - .map_err(|_| Error::ConversionFailure)?; + let image = image_from_pixels(pixels, data.width, data.height)?; self.clipboard.clear(); - let image_array: Id> = NSArray::from_vec(vec![image]); - // Make sure that we pass a pointer to the system and not the array object itself. - let success = unsafe { msg_send![self.clipboard.pasteboard, writeObjects: &*image_array] }; + let image_array = NSArray::from_retained_slice(&[ProtocolObject::from_retained(image)]); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&image_array) }; + + add_clipboard_exclusions(self.clipboard, self.exclude_from_history); + if success { Ok(()) } else { - Err(Error::Unknown { - description: - "Failed to write the image to the pasteboard (`writeObjects` returned NO)." - .into(), + Err(Error::unknown( + "Failed to write the image to the pasteboard (`writeObjects` returned NO).", + )) + } + } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + self.clipboard.clear(); + + let uri_list = file_list + .iter() + .filter_map(|path| { + path.as_ref().canonicalize().ok().and_then(|abs_path| { + abs_path.to_str().map(|str| { + let url = unsafe { NSURL::fileURLWithPath(&NSString::from_str(str)) }; + ProtocolObject::from_retained(url) + }) + }) }) + .collect::>(); + + if uri_list.is_empty() { + return Err(Error::ConversionFailure); + } + + let objects = NSArray::from_retained_slice(&uri_list); + let success = unsafe { self.clipboard.pasteboard.writeObjects(&objects) }; + + add_clipboard_exclusions(self.clipboard, self.exclude_from_history); + + if success { + Ok(()) + } else { + Err(Error::unknown("NSPasteboard#writeObjects: returned false")) } } } @@ -343,3 +406,33 @@ impl<'clipboard> Clear<'clipboard> { Ok(()) } } + +fn add_clipboard_exclusions(clipboard: &mut Clipboard, exclude_from_history: bool) { + // On Mac there isn't an official standard for excluding data from clipboard, however + // there is an unofficial standard which is to set `org.nspasteboard.ConcealedType`. + // + // See http://nspasteboard.org/ for details about the community standard. + if exclude_from_history { + unsafe { + clipboard + .pasteboard + .setString_forType(ns_string!(""), ns_string!("org.nspasteboard.ConcealedType")); + } + } +} + +/// Apple-specific extensions to the [`Set`](crate::Set) builder. +pub trait SetExtApple: private::Sealed { + /// Excludes the data which will be set on the clipboard from being added to + /// third party clipboard history software. + /// + /// See http://nspasteboard.org/ for details about the community standard. + fn exclude_from_history(self) -> Self; +} + +impl SetExtApple for crate::Set<'_> { + fn exclude_from_history(mut self) -> Self { + self.platform.exclude_from_history = true; + self + } +} diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 39bdc2e..cb11598 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -8,346 +8,501 @@ the Apache 2.0 or the MIT license at the licensee's choice. The terms and conditions of the chosen license apply to this file. */ -use std::{borrow::Cow, marker::PhantomData}; #[cfg(feature = "image-data")] -use std::{convert::TryInto, mem::size_of}; - -#[cfg(feature = "image-data")] -use winapi::{ - shared::minwindef::DWORD, - um::{ - errhandlingapi::GetLastError, - winbase::{GlobalLock, GlobalUnlock}, - wingdi::{ - CreateDIBitmap, DeleteObject, GetDIBits, LCS_sRGB, BITMAPINFO, BITMAPINFOHEADER, - BITMAPV5HEADER, BI_RGB, CBM_INIT, DIB_RGB_COLORS, LCS_GM_IMAGES, PROFILE_EMBEDDED, - PROFILE_LINKED, RGBQUAD, - }, - winnt::LONG, - winuser::{GetDC, SetClipboardData}, +use crate::common::ImageData; +use crate::common::{private, Error}; +use std::{ + borrow::Cow, + io, + marker::PhantomData, + os::windows::{fs::OpenOptionsExt, io::AsRawHandle}, + path::{Path, PathBuf}, + thread, + time::Duration, +}; +use windows_sys::Win32::{ + Foundation::{GetLastError, GlobalFree, HANDLE, HGLOBAL, POINT, S_OK}, + Storage::FileSystem::{GetFinalPathNameByHandleW, FILE_FLAG_BACKUP_SEMANTICS, VOLUME_NAME_DOS}, + System::{ + DataExchange::SetClipboardData, + Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GHND}, + Ole::CF_HDROP, }, + UI::Shell::{PathCchStripPrefix, DROPFILES}, }; -use crate::common::{private, Error}; - #[cfg(feature = "image-data")] -use crate::common::{ImageData, ScopeGuard}; - -#[cfg(feature = "image-data")] -fn add_cf_dibv5(_open_clipboard: OpenClipboard, image: ImageData) -> Result<(), Error> { - use std::intrinsics::copy_nonoverlapping; - use winapi::um::{ - winbase::{GlobalAlloc, GHND}, - wingdi::BI_BITFIELDS, - winuser::CF_DIBV5, +mod image_data { + use super::*; + use crate::common::ScopeGuard; + use image::codecs::bmp::BmpDecoder; + use image::codecs::png::PngDecoder; + use image::codecs::png::PngEncoder; + use image::DynamicImage; + use image::ExtendedColorType; + use image::ImageDecoder; + use image::ImageEncoder; + use std::{convert::TryInto, mem::size_of, ptr::copy_nonoverlapping}; + use windows_sys::Win32::{ + Graphics::Gdi::{ + DeleteObject, BITMAPV5HEADER, BI_BITFIELDS, BI_RGB, HGDIOBJ, LCS_GM_IMAGES, + }, + System::Ole::CF_DIBV5, }; - let header_size = size_of::(); - let header = BITMAPV5HEADER { - bV5Size: header_size as u32, - bV5Width: image.width as LONG, - bV5Height: image.height as LONG, - bV5Planes: 1, - bV5BitCount: 32, - bV5Compression: BI_BITFIELDS, - bV5SizeImage: (4 * image.width * image.height) as DWORD, - bV5XPelsPerMeter: 0, - bV5YPelsPerMeter: 0, - bV5ClrUsed: 0, - bV5ClrImportant: 0, - bV5RedMask: 0x00ff0000, - bV5GreenMask: 0x0000ff00, - bV5BlueMask: 0x000000ff, - bV5AlphaMask: 0xff000000, - bV5CSType: LCS_sRGB as u32, - // SAFETY: Windows ignores this field because `bV5CSType` is not set to `LCS_CALIBRATED_RGB`. - bV5Endpoints: unsafe { std::mem::zeroed() }, - bV5GammaRed: 0, - bV5GammaGreen: 0, - bV5GammaBlue: 0, - bV5Intent: LCS_GM_IMAGES as u32, // I'm not sure about this. - bV5ProfileData: 0, - bV5ProfileSize: 0, - bV5Reserved: 0, - }; + pub(super) fn add_cf_dibv5( + _open_clipboard: OpenClipboard, + image: ImageData, + ) -> Result<(), Error> { + // This constant is missing in windows-rs + // https://github.com/microsoft/windows-rs/issues/2711 + #[allow(non_upper_case_globals)] + const LCS_sRGB: u32 = 0x7352_4742; - // In theory we don't need to flip the image because we could just specify - // a negative height in the header, which according to the documentation, indicates that the - // image rows are in top-to-bottom order. HOWEVER: MS Word (and WordPad) cannot paste an image - // that has a negative height in its header. - let image = flip_v(image); - - let data_size = header_size + image.bytes.len(); - let hdata = unsafe { GlobalAlloc(GHND, data_size) }; - if hdata.is_null() { - return Err(Error::Unknown { - description: format!( - "Could not allocate global memory object. GlobalAlloc returned null at line {}.", - line!() - ), - }); - } - unsafe { - let data_ptr = GlobalLock(hdata) as *mut u8; - if data_ptr.is_null() { - return Err(Error::Unknown { - description: format!("Could not lock the global memory object at line {}", line!()), - }); - } - - let _unlock = ScopeGuard::new(|| { - let retval = GlobalUnlock(hdata); - if retval == 0 { - let lasterr = GetLastError(); - if lasterr != 0 { - log::error!("Failed calling GlobalUnlock when writing dibv5 data. Error code was 0x{:X}", lasterr); - } - } - }); - - copy_nonoverlapping::((&header) as *const _ as *const u8, data_ptr, header_size); - - // Not using the `add` function, because that has a restriction, that the result cannot overflow isize - let pixels_dst = (data_ptr as usize + header_size) as *mut u8; - copy_nonoverlapping::(image.bytes.as_ptr(), pixels_dst, image.bytes.len()); - - let dst_pixels_slice = std::slice::from_raw_parts_mut(pixels_dst, image.bytes.len()); - - // If the non-allocating version of the function failed, we need to assign the new bytes to - // the global allocation. - if let Cow::Owned(new_pixels) = rgba_to_win(dst_pixels_slice) { - // SAFETY: `data_ptr` is valid to write to and has no outstanding mutable borrows, and - // `new_pixels` will be the same length as the original bytes. - copy_nonoverlapping::(new_pixels.as_ptr(), data_ptr, new_pixels.len()) - } - } - - unsafe { - if SetClipboardData(CF_DIBV5, hdata as _).is_null() { - DeleteObject(hdata as _); - return Err(Error::Unknown { - description: format!( - "Call to `SetClipboardData` returned NULL at line {}", - line!() - ), - }); - } - } - - Ok(()) -} - -#[cfg(feature = "image-data")] -fn read_cf_dibv5(dibv5: &[u8]) -> Result, Error> { - // The DIBV5 format is a BITMAPV5HEADER followed by the pixel data according to - // https://docs.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats - - // so first let's get a pointer to the header - let header_size = size_of::(); - if dibv5.len() < header_size { - return Err(Error::Unknown { - description: "When reading the DIBV5 data, it contained fewer bytes than the BITMAPV5HEADER size. This is invalid.".into() - }); - } - let header = unsafe { &*(dibv5.as_ptr() as *const BITMAPV5HEADER) }; - - let has_profile = - header.bV5CSType as i32 == PROFILE_LINKED || header.bV5CSType as i32 == PROFILE_EMBEDDED; - - let pixel_data_start = if has_profile { - header.bV5ProfileData as isize + header.bV5ProfileSize as isize - } else { - header_size as isize - }; - - unsafe { - let image_bytes = dibv5.as_ptr().offset(pixel_data_start) as *const _; - let hdc = GetDC(std::ptr::null_mut()); - let hbitmap = CreateDIBitmap( - hdc, - header as *const BITMAPV5HEADER as *const _, - CBM_INIT, - image_bytes, - header as *const BITMAPV5HEADER as *const _, - DIB_RGB_COLORS, - ); - if hbitmap.is_null() { - return Err(Error::Unknown { - description: - "Failed to create the HBITMAP while reading DIBV5. CreateDIBitmap returned null" - .into(), - }); - } - // Now extract the pixels in a desired format - let w = header.bV5Width; - let h = header.bV5Height.abs(); - let result_size = w as usize * h as usize * 4; - - let mut result_bytes = Vec::::with_capacity(result_size); - - let mut output_header = BITMAPINFO { - bmiColors: [RGBQUAD { rgbRed: 0, rgbGreen: 0, rgbBlue: 0, rgbReserved: 0 }], - bmiHeader: BITMAPINFOHEADER { - biSize: size_of::() as u32, - biWidth: w, - biHeight: -h, - biBitCount: 32, - biPlanes: 1, - biCompression: BI_RGB, - biSizeImage: 0, - biXPelsPerMeter: 0, - biYPelsPerMeter: 0, - biClrUsed: 0, - biClrImportant: 0, - }, + let header_size = size_of::(); + let header = BITMAPV5HEADER { + bV5Size: header_size as u32, + bV5Width: image.width as i32, + bV5Height: image.height as i32, + bV5Planes: 1, + bV5BitCount: 32, + bV5Compression: BI_BITFIELDS, + bV5SizeImage: (4 * image.width * image.height) as u32, + bV5XPelsPerMeter: 0, + bV5YPelsPerMeter: 0, + bV5ClrUsed: 0, + bV5ClrImportant: 0, + bV5RedMask: 0x00ff0000, + bV5GreenMask: 0x0000ff00, + bV5BlueMask: 0x000000ff, + bV5AlphaMask: 0xff000000, + bV5CSType: LCS_sRGB, + // SAFETY: Windows ignores this field because `bV5CSType` is not set to `LCS_CALIBRATED_RGB`. + bV5Endpoints: unsafe { std::mem::zeroed() }, + bV5GammaRed: 0, + bV5GammaGreen: 0, + bV5GammaBlue: 0, + bV5Intent: LCS_GM_IMAGES as u32, // I'm not sure about this. + bV5ProfileData: 0, + bV5ProfileSize: 0, + bV5Reserved: 0, }; - let result = GetDIBits( - hdc, - hbitmap, - 0, - h as u32, - result_bytes.as_mut_ptr() as *mut _, - &mut output_header as *mut _, - DIB_RGB_COLORS, - ); - if result == 0 { - return Err(Error::Unknown { - description: "Could not get the bitmap bits, GetDIBits returned 0".into(), - }); + // In theory we don't need to flip the image because we could just specify + // a negative height in the header, which according to the documentation, indicates that the + // image rows are in top-to-bottom order. HOWEVER: MS Word (and WordPad) cannot paste an image + // that has a negative height in its header. + let image = flip_v(image); + + let data_size = header_size + image.bytes.len(); + let hdata = unsafe { global_alloc(data_size)? }; + unsafe { + let data_ptr = global_lock(hdata)?; + let _unlock = ScopeGuard::new(|| global_unlock_checked(hdata)); + + copy_nonoverlapping::( + (&header as *const BITMAPV5HEADER).cast(), + data_ptr, + header_size, + ); + + // Not using the `add` function, because that has a restriction, that the result cannot overflow isize + let pixels_dst = data_ptr.add(header_size); + copy_nonoverlapping::(image.bytes.as_ptr(), pixels_dst, image.bytes.len()); + + let dst_pixels_slice = std::slice::from_raw_parts_mut(pixels_dst, image.bytes.len()); + + // If the non-allocating version of the function failed, we need to assign the new bytes to + // the global allocation. + if let Cow::Owned(new_pixels) = rgba_to_win(dst_pixels_slice) { + // SAFETY: `data_ptr` is valid to write to and has no outstanding mutable borrows, and + // `new_pixels` will be the same length as the original bytes. + copy_nonoverlapping::(new_pixels.as_ptr(), data_ptr, new_pixels.len()) + } } - let read_len = result as usize * w as usize * 4; - if read_len > result_bytes.capacity() { - panic!("Segmentation fault. Read more bytes than allocated to pixel buffer"); - } - result_bytes.set_len(read_len); - let result_bytes = win_to_rgba(&mut result_bytes); - - let result = - ImageData { bytes: Cow::Owned(result_bytes), width: w as usize, height: h as usize }; - Ok(result) - } -} - -/// Converts the RGBA (u8) pixel data into the bitmap-native ARGB (u32) format in-place -/// -/// Safety: the `bytes` slice must have a length that's a multiple of 4 -#[cfg(feature = "image-data")] -#[allow(clippy::identity_op, clippy::erasing_op)] -#[must_use] -unsafe fn rgba_to_win(bytes: &mut [u8]) -> Cow<'_, [u8]> { - // Check safety invariants to catch obvious bugs. - debug_assert_eq!(bytes.len() % 4, 0); - - let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); - let u32pixels = match u32pixels_buffer { - ImageDataCow::Borrowed(ref mut b) => b, - ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), - }; - - for p in u32pixels.iter_mut() { - let [mut r, mut g, mut b, mut a] = p.to_ne_bytes().map(u32::from); - r <<= 2 * 8; - g <<= 1 * 8; - b <<= 0 * 8; - a <<= 3 * 8; - - *p = r | g | b | a; - } - - match u32pixels_buffer { - ImageDataCow::Borrowed(_) => Cow::Borrowed(bytes), - ImageDataCow::Owned(bytes) => { - Cow::Owned(bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect()) + if unsafe { SetClipboardData(CF_DIBV5 as u32, hdata as HANDLE) }.failure() { + unsafe { DeleteObject(hdata as HGDIOBJ) }; + Err(last_error("SetClipboardData failed with error")) + } else { + Ok(()) } } -} -/// Vertically flips the image pixels in memory -#[cfg(feature = "image-data")] -fn flip_v(image: ImageData) -> ImageData<'static> { - let w = image.width; - let h = image.height; + pub(super) fn add_png_file(image: &ImageData) -> Result<(), Error> { + // Try encoding the image as PNG. + let mut buf = Vec::new(); + let encoder = PngEncoder::new(&mut buf); - let mut bytes = image.bytes.into_owned(); + encoder + .write_image( + &image.bytes, + image.width as u32, + image.height as u32, + ExtendedColorType::Rgba8, + ) + .map_err(|_| Error::ConversionFailure)?; - let rowsize = w * 4; // each pixel is 4 bytes - let mut tmp_a = Vec::new(); - tmp_a.resize(rowsize, 0); - // I believe this could be done safely with `as_chunks_mut`, but that's not stable yet - for a_row_id in 0..(h / 2) { - let b_row_id = h - a_row_id - 1; + // Register PNG format. + let format_id = match clipboard_win::register_format("PNG") { + Some(format_id) => format_id.into(), + None => return Err(last_error("Cannot register PNG clipboard format.")), + }; - // swap rows `first_id` and `second_id` - let a_byte_start = a_row_id * rowsize; - let a_byte_end = a_byte_start + rowsize; - let b_byte_start = b_row_id * rowsize; - let b_byte_end = b_byte_start + rowsize; - tmp_a.copy_from_slice(&bytes[a_byte_start..a_byte_end]); - bytes.copy_within(b_byte_start..b_byte_end, a_byte_start); - bytes[b_byte_start..b_byte_end].copy_from_slice(&tmp_a); + let data_size = buf.len(); + let hdata = unsafe { global_alloc(data_size)? }; + + unsafe { + let pixels_dst = global_lock(hdata)?; + copy_nonoverlapping::(buf.as_ptr(), pixels_dst, data_size); + global_unlock_checked(hdata); + } + + if unsafe { SetClipboardData(format_id, hdata as HANDLE) }.failure() { + unsafe { DeleteObject(hdata as HGDIOBJ) }; + Err(last_error("SetClipboardData failed with error")) + } else { + Ok(()) + } } - ImageData { width: image.width, height: image.height, bytes: bytes.into() } -} - -/// Converts the ARGB (u32) pixel data into the RGBA (u8) format in-place -/// -/// Safety: the `bytes` slice must have a length that's a multiple of 4 -#[cfg(feature = "image-data")] -#[allow(clippy::identity_op, clippy::erasing_op)] -#[must_use] -unsafe fn win_to_rgba(bytes: &mut [u8]) -> Vec { - // Check safety invariants to catch obvious bugs. - debug_assert_eq!(bytes.len() % 4, 0); - - let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); - let u32pixels = match u32pixels_buffer { - ImageDataCow::Borrowed(ref mut b) => b, - ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), - }; - - for p in u32pixels { - let mut bytes = p.to_ne_bytes(); - bytes[0] = (*p >> (2 * 8)) as u8; - bytes[1] = (*p >> (1 * 8)) as u8; - bytes[2] = (*p >> (0 * 8)) as u8; - bytes[3] = (*p >> (3 * 8)) as u8; - *p = u32::from_ne_bytes(bytes); - } - - match u32pixels_buffer { - ImageDataCow::Borrowed(_) => bytes.to_vec(), - ImageDataCow::Owned(bytes) => bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect(), - } -} - -#[cfg(feature = "image-data")] -// XXX: std's Cow is not usable here because it does not allow mutably -// borrowing data. -enum ImageDataCow<'a> { - Borrowed(&'a mut [u32]), - Owned(Vec), -} - -/// Safety: the `bytes` slice must have a length that's a multiple of 4 -#[cfg(feature = "image-data")] -unsafe fn convert_bytes_to_u32s(bytes: &mut [u8]) -> ImageDataCow<'_> { - // When the correct conditions are upheld, `std` should return everything in the well-aligned slice. - let (prefix, _, suffix) = bytes.align_to::(); - - // Check if `align_to` gave us the optimal result. + // https://learn.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header + // According to the docs, when bV5Compression is BI_RGB, "the high byte in each DWORD + // is not used". + // This seems to not be respected in the real world. For example, Chrome, and Chromium + // & Electron-based programs send us BI_RGB headers, but with bitCount=32 - and important + // transparency bytes in the alpha channel. // - // If it didn't, use the slow path with more allocations - if prefix.is_empty() && suffix.is_empty() { - // We know that the newly-aligned slice will contain all the values - ImageDataCow::Borrowed(bytes.align_to_mut::().1) + // Apparently, it's our job as the consumer to do the right thing. This method fiddles + // with the header a bit in these cases, then `image` handles the rest. + fn maybe_tweak_header(dibv5: &mut [u8]) { + assert!(dibv5.len() >= size_of::()); + let src = dibv5.as_mut_ptr().cast::(); + let mut header = unsafe { std::ptr::read_unaligned(src) }; + + if header.bV5BitCount == 32 + && header.bV5Compression == BI_RGB + && header.bV5AlphaMask == 0xff000000 + { + header.bV5Compression = BI_BITFIELDS; + if header.bV5RedMask == 0 && header.bV5GreenMask == 0 && header.bV5BlueMask == 0 { + header.bV5RedMask = 0xff0000; + header.bV5GreenMask = 0xff00; + header.bV5BlueMask = 0xff; + } + + unsafe { std::ptr::write_unaligned(src, header) }; + } + } + + pub(super) fn read_cf_dibv5(dibv5: &mut [u8]) -> Result, Error> { + // The DIBV5 format is a BITMAPV5HEADER followed by the pixel data according to + // https://docs.microsoft.com/en-us/windows/win32/dataxchg/standard-clipboard-formats + + let header_size = size_of::(); + if dibv5.len() < header_size { + return Err(Error::unknown("When reading the DIBV5 data, it contained fewer bytes than the BITMAPV5HEADER size. This is invalid.")); + } + maybe_tweak_header(dibv5); + + let decoder = BmpDecoder::new_without_file_header(std::io::Cursor::new(&*dibv5)) + .map_err(|_| Error::ConversionFailure)?; + let (width, height) = decoder.dimensions(); + let bytes = DynamicImage::from_decoder(decoder) + .map_err(|_| Error::ConversionFailure)? + .into_rgba8() + .into_raw(); + + Ok(ImageData { width: width as usize, height: height as usize, bytes: bytes.into() }) + } + + pub(super) fn read_png(data: &[u8]) -> Result, Error> { + let decoder = + PngDecoder::new(std::io::Cursor::new(data)).map_err(|_| Error::ConversionFailure)?; + let (width, height) = decoder.dimensions(); + + let bytes = DynamicImage::from_decoder(decoder) + .map_err(|_| Error::ConversionFailure)? + .into_rgba8() + .into_raw(); + + Ok(ImageData { width: width as usize, height: height as usize, bytes: bytes.into() }) + } + + /// Converts the RGBA (u8) pixel data into the bitmap-native ARGB (u32) + /// format in-place. + /// + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + #[allow(clippy::identity_op, clippy::erasing_op)] + #[must_use] + unsafe fn rgba_to_win(bytes: &mut [u8]) -> Cow<'_, [u8]> { + // Check safety invariants to catch obvious bugs. + debug_assert_eq!(bytes.len() % 4, 0); + + let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); + let u32pixels = match u32pixels_buffer { + ImageDataCow::Borrowed(ref mut b) => b, + ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), + }; + + for p in u32pixels.iter_mut() { + let [mut r, mut g, mut b, mut a] = p.to_ne_bytes().map(u32::from); + r <<= 2 * 8; + g <<= 1 * 8; + b <<= 0 * 8; + a <<= 3 * 8; + + *p = r | g | b | a; + } + + match u32pixels_buffer { + ImageDataCow::Borrowed(_) => Cow::Borrowed(bytes), + ImageDataCow::Owned(bytes) => { + Cow::Owned(bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect()) + } + } + } + + /// Vertically flips the image pixels in memory + fn flip_v(image: ImageData) -> ImageData<'static> { + let w = image.width; + let h = image.height; + + let mut bytes = image.bytes.into_owned(); + + let rowsize = w * 4; // each pixel is 4 bytes + let mut tmp_a = vec![0; rowsize]; + // I believe this could be done safely with `as_chunks_mut`, but that's not stable yet + for a_row_id in 0..(h / 2) { + let b_row_id = h - a_row_id - 1; + + // swap rows `first_id` and `second_id` + let a_byte_start = a_row_id * rowsize; + let a_byte_end = a_byte_start + rowsize; + let b_byte_start = b_row_id * rowsize; + let b_byte_end = b_byte_start + rowsize; + tmp_a.copy_from_slice(&bytes[a_byte_start..a_byte_end]); + bytes.copy_within(b_byte_start..b_byte_end, a_byte_start); + bytes[b_byte_start..b_byte_end].copy_from_slice(&tmp_a); + } + + ImageData { width: image.width, height: image.height, bytes: bytes.into() } + } + + /// Converts the ARGB (u32) pixel data into the RGBA (u8) format in-place + /// + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + #[allow(clippy::identity_op, clippy::erasing_op)] + #[must_use] + #[cfg(test)] + unsafe fn win_to_rgba(bytes: &mut [u8]) -> Vec { + // Check safety invariants to catch obvious bugs. + debug_assert_eq!(bytes.len() % 4, 0); + + let mut u32pixels_buffer = convert_bytes_to_u32s(bytes); + let u32pixels = match u32pixels_buffer { + ImageDataCow::Borrowed(ref mut b) => b, + ImageDataCow::Owned(ref mut b) => b.as_mut_slice(), + }; + + for p in u32pixels { + let mut bytes = p.to_ne_bytes(); + bytes[0] = (*p >> (2 * 8)) as u8; + bytes[1] = (*p >> (1 * 8)) as u8; + bytes[2] = (*p >> (0 * 8)) as u8; + bytes[3] = (*p >> (3 * 8)) as u8; + *p = u32::from_ne_bytes(bytes); + } + + match u32pixels_buffer { + ImageDataCow::Borrowed(_) => bytes.to_vec(), + ImageDataCow::Owned(bytes) => bytes.into_iter().flat_map(|b| b.to_ne_bytes()).collect(), + } + } + + // XXX: std's Cow is not usable here because it does not allow mutably + // borrowing data. + enum ImageDataCow<'a> { + Borrowed(&'a mut [u32]), + Owned(Vec), + } + + /// Safety: the `bytes` slice must have a length that's a multiple of 4 + unsafe fn convert_bytes_to_u32s(bytes: &mut [u8]) -> ImageDataCow<'_> { + // When the correct conditions are upheld, `std` should return everything in the well-aligned slice. + let (prefix, _, suffix) = bytes.align_to::(); + + // Check if `align_to` gave us the optimal result. + // + // If it didn't, use the slow path with more allocations + if prefix.is_empty() && suffix.is_empty() { + // We know that the newly-aligned slice will contain all the values + ImageDataCow::Borrowed(bytes.align_to_mut::().1) + } else { + // XXX: Use `as_chunks` when it stabilizes. + let u32pixels_buffer = bytes + .chunks(4) + .map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap())) + .collect(); + ImageDataCow::Owned(u32pixels_buffer) + } + } + + #[test] + fn conversion_between_win_and_rgba() { + const DATA: [u8; 16] = + [100, 100, 255, 100, 0, 0, 0, 255, 255, 100, 100, 255, 100, 255, 100, 100]; + + let mut data = DATA; + let _converted = unsafe { win_to_rgba(&mut data) }; + + let mut data = DATA; + let _converted = unsafe { rgba_to_win(&mut data) }; + + let mut data = DATA; + let _converted = unsafe { win_to_rgba(&mut data) }; + let _converted = unsafe { rgba_to_win(&mut data) }; + assert_eq!(data, DATA); + + let mut data = DATA; + let _converted = unsafe { rgba_to_win(&mut data) }; + let _converted = unsafe { win_to_rgba(&mut data) }; + assert_eq!(data, DATA); + } + + #[test] + fn firefox_dibv5() { + // A 5x5 sample of https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png + let mut raw = vec![ + 124, 0, 0, 0, 5, 0, 0, 0, 5, 0, 0, 0, 1, 0, 24, 0, 0, 0, 0, 0, 80, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 0, 0, + 255, 66, 71, 82, 115, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 36, 47, 144, 42, 68, 110, 48, 74, 66, 52, 74, + 49, 57, 80, 55, 0, 36, 53, 138, 45, 79, 98, 52, 82, 58, 56, 84, 52, 62, 91, 58, 0, 37, + 64, 129, 48, 88, 88, 54, 90, 54, 60, 96, 55, 66, 104, 62, 0, 40, 75, 120, 50, 96, 74, + 55, 99, 51, 62, 106, 57, 68, 113, 62, 0, 42, 89, 107, 50, 104, 60, 57, 108, 49, 64, + 114, 56, 71, 123, 65, 0, + ]; + + let before = raw.clone(); + let image = read_cf_dibv5(&mut raw).unwrap(); + + // Not expecting any header fiddling to happen here. This is a bitmap in 24-bit format, with a header + // that says as much + assert_eq!(raw, before); + + assert_eq!(image.width, 5); + assert_eq!(image.height, 5); + + const EXPECTED: &[u8] = &[ + 107, 89, 42, 255, 60, 104, 50, 255, 49, 108, 57, 255, 56, 114, 64, 255, 65, 123, 71, + 255, 120, 75, 40, 255, 74, 96, 50, 255, 51, 99, 55, 255, 57, 106, 62, 255, 62, 113, 68, + 255, 129, 64, 37, 255, 88, 88, 48, 255, 54, 90, 54, 255, 55, 96, 60, 255, 62, 104, 66, + 255, 138, 53, 36, 255, 98, 79, 45, 255, 58, 82, 52, 255, 52, 84, 56, 255, 58, 91, 62, + 255, 144, 47, 36, 255, 110, 68, 42, 255, 66, 74, 48, 255, 49, 74, 52, 255, 55, 80, 57, + 255, + ]; + assert_eq!(image.bytes, EXPECTED); + } + + #[test] + fn chrome_dibv5() { + // A 5x5 sample of https://commons.wikimedia.org/wiki/File:PNG_transparency_demonstration_1.png + // (interestingly, the same sample as in the Firefox test - despite the pixel data being + // materially different!) + let mut raw = vec![ + 124, 0, 0, 0, 5, 0, 0, 0, 5, 0, 0, 0, 1, 0, 32, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, + 32, 110, 105, 87, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 32, 38, 145, 192, 38, 65, 111, 158, 46, 73, 68, + 107, 50, 73, 50, 92, 55, 79, 55, 100, 31, 46, 139, 190, 41, 76, 100, 152, 49, 81, 60, + 110, 53, 83, 53, 108, 60, 91, 60, 118, 32, 59, 131, 187, 44, 86, 89, 150, 51, 89, 56, + 121, 57, 95, 57, 127, 63, 103, 63, 139, 35, 71, 122, 186, 46, 95, 76, 150, 52, 99, 54, + 136, 59, 105, 59, 146, 65, 113, 65, 156, 37, 86, 109, 184, 46, 103, 63, 155, 52, 107, + 53, 152, 60, 114, 60, 162, 68, 123, 68, 174, + ]; + + let before = raw.clone(); + let image = read_cf_dibv5(&mut raw).unwrap(); + + // Chrome's header is dodgy. Expect that we fiddled with it. + assert_ne!(raw, before); + + assert_eq!(image.width, 5); + assert_eq!(image.height, 5); + + const EXPECTED: &[u8] = &[ + 109, 86, 37, 184, 63, 103, 46, 155, 53, 107, 52, 152, 60, 114, 60, 162, 68, 123, 68, + 174, 122, 71, 35, 186, 76, 95, 46, 150, 54, 99, 52, 136, 59, 105, 59, 146, 65, 113, 65, + 156, 131, 59, 32, 187, 89, 86, 44, 150, 56, 89, 51, 121, 57, 95, 57, 127, 63, 103, 63, + 139, 139, 46, 31, 190, 100, 76, 41, 152, 60, 81, 49, 110, 53, 83, 53, 108, 60, 91, 60, + 118, 145, 38, 32, 192, 111, 65, 38, 158, 68, 73, 46, 107, 50, 73, 50, 92, 55, 79, 55, + 100, + ]; + assert_eq!(image.bytes, EXPECTED); + } +} + +unsafe fn global_alloc(bytes: usize) -> Result { + let hdata = GlobalAlloc(GHND, bytes); + if hdata.is_null() { + Err(last_error("Could not allocate global memory object")) } else { - // XXX: Use `as_chunks` when it stabilizes. - let u32pixels_buffer = - bytes.chunks(4).map(|chunk| u32::from_ne_bytes(chunk.try_into().unwrap())).collect(); - ImageDataCow::Owned(u32pixels_buffer) + Ok(hdata) + } +} + +unsafe fn global_lock(hmem: HGLOBAL) -> Result<*mut u8, Error> { + let data_ptr = GlobalLock(hmem).cast::(); + if data_ptr.is_null() { + Err(last_error("Could not lock the global memory object")) + } else { + Ok(data_ptr) + } +} + +unsafe fn global_unlock_checked(hdata: HGLOBAL) { + // If the memory object is unlocked after decrementing the lock count, the function + // returns zero and GetLastError returns NO_ERROR. If it fails, the return value is + // zero and GetLastError returns a value other than NO_ERROR. + if GlobalUnlock(hdata) == 0 { + let err = io::Error::last_os_error(); + if err.raw_os_error() != Some(0) { + log::error!("Failed calling GlobalUnlock when writing data: {}", err); + } + } +} + +fn last_error(message: &str) -> Error { + let os_error = io::Error::last_os_error(); + Error::unknown(format!("{message}: {os_error}")) +} + +/// An abstraction trait over the different ways a Win32 function may return +/// a value with a failure marker. +/// +/// This trait helps unify error handling across varying `windows-sys` versions, +/// providing a consistent interface for representing NULL values. +trait ResultValue: Sized { + const NULL: Self; + fn failure(self) -> bool; +} + +// windows-sys >= 0.59 +impl ResultValue for *mut T { + const NULL: Self = core::ptr::null_mut(); + fn failure(self) -> bool { + self == Self::NULL + } +} + +// `windows-sys` 0.52 +impl ResultValue for isize { + const NULL: Self = 0; + fn failure(self) -> bool { + self == Self::NULL } } @@ -381,7 +536,7 @@ impl Clipboard { Ok(Self(())) } - fn open(&mut self) -> Result { + fn open(&mut self) -> Result, Error> { // Attempt to open the clipboard multiple times. On Windows, its common for something else to temporarily // be using it during attempts. // @@ -401,8 +556,7 @@ impl Clipboard { } // The default value matches Chromium's implementation, but could be tweaked later. - // Safety: This is safe to call with any integer. - unsafe { winapi::um::synchapi::Sleep(5) }; + thread::sleep(Duration::from_millis(5)); } .map_err(|_| Error::ClipboardOccupied)?; @@ -438,79 +592,97 @@ impl<'clipboard> Get<'clipboard> { return Err(Error::ContentNotAvailable); } - let text_size = clipboard_win::raw::size(FORMAT).ok_or_else(|| Error::Unknown { - description: "failed to read clipboard text size".into(), - })?; + // NB: Its important that whatever functionality decodes the text buffer from the clipboard + // uses `WideCharToMultiByte` with `CP_UTF8` (or an equivalent) in order to handle when both "text" + // and a locale identifier were placed on the clipboard. It is probable this occurs when an application + // is running with a codepage that isn't the current system's, such as under a locale emulator. + // + // In these cases, Windows decodes the text buffer with whatever codepage that identifier is for + // when creating the `CF_UNICODETEXT` buffer. Therefore, the buffer could then be in any format, + // not nessecarily wide UTF-16. We need to then undo that, taking the wide data and mapping it into + // the UTF-8 space as best as possible. + // + // (locale-specific text data, locale id) -> app -> system -> arboard (locale-specific text data) -> UTF-8 + let mut out = Vec::new(); + clipboard_win::raw::get_string(&mut out).map_err(|_| Error::ContentNotAvailable)?; + String::from_utf8(out).map_err(|_| Error::ConversionFailure) + } - // Allocate the specific number of WTF-16 characters we need to receive. - // This division is always accurate because Windows uses 16-bit characters. - let mut out: Vec = vec![0u16; text_size.get() / 2]; + pub(crate) fn html(self) -> Result { + let _clipboard_assertion = self.clipboard?; - let bytes_read = { - // SAFETY: The source slice has a greater alignment than the resulting one. - let out: &mut [u8] = - unsafe { std::slice::from_raw_parts_mut(out.as_mut_ptr().cast(), out.len() * 2) }; + let format = clipboard_win::register_format("HTML Format") + .ok_or_else(|| Error::unknown("unable to register HTML format"))?; - let mut bytes_read = clipboard_win::raw::get(FORMAT, out).map_err(|_| { - Error::Unknown { description: "failed to read clipboard string".into() } - })?; + let mut out: Vec = Vec::new(); + clipboard_win::raw::get_html(format.get(), &mut out) + .map_err(|_| Error::unknown("failed to read clipboard string"))?; - // Convert the number of bytes read to the number of `u16`s - bytes_read /= 2; - - // Remove the NUL terminator, if it existed. - if let Some(last) = out.last().copied() { - if last == 0 { - bytes_read -= 1; - } - } - - bytes_read - }; - - // Create a UTF-8 string from WTF-16 data, if it was valid. - String::from_utf16(&out[..bytes_read]).map_err(|_| Error::ConversionFailure) + String::from_utf8(out).map_err(|_| Error::ConversionFailure) } #[cfg(feature = "image-data")] pub(crate) fn image(self) -> Result, Error> { - const FORMAT: u32 = clipboard_win::formats::CF_DIBV5; - let _clipboard_assertion = self.clipboard?; + let mut data = Vec::new(); - if !clipboard_win::is_format_avail(FORMAT) { + let png_format: Option = clipboard_win::register_format("PNG").map(From::from); + if let Some(id) = png_format.filter(|&id| clipboard_win::is_format_avail(id)) { + // Looks like PNG is available! Let's try it + clipboard_win::raw::get_vec(id, &mut data) + .map_err(|_| Error::unknown("failed to read clipboard PNG data"))?; + return image_data::read_png(&data); + } + + if !clipboard_win::is_format_avail(clipboard_win::formats::CF_DIBV5) { return Err(Error::ContentNotAvailable); } - let mut data = Vec::new(); + clipboard_win::raw::get_vec(clipboard_win::formats::CF_DIBV5, &mut data) + .map_err(|_| Error::unknown("failed to read clipboard image data"))?; + image_data::read_cf_dibv5(&mut data) + } - clipboard_win::raw::get_vec(FORMAT, &mut data).map_err(|_| Error::Unknown { - description: "failed to read clipboard image data".into(), - })?; + pub(crate) fn file_list(self) -> Result, Error> { + let _clipboard_assertion = self.clipboard?; - read_cf_dibv5(&data) + let mut file_list = Vec::new(); + clipboard_win::raw::get_file_list_path(&mut file_list) + .map_err(|_| Error::ContentNotAvailable)?; + + Ok(file_list) } } pub(crate) struct Set<'clipboard> { clipboard: Result, Error>, + exclude_from_monitoring: bool, exclude_from_cloud: bool, exclude_from_history: bool, } impl<'clipboard> Set<'clipboard> { pub(crate) fn new(clipboard: &'clipboard mut Clipboard) -> Self { - Self { clipboard: clipboard.open(), exclude_from_cloud: false, exclude_from_history: false } + Self { + clipboard: clipboard.open(), + exclude_from_monitoring: false, + exclude_from_cloud: false, + exclude_from_history: false, + } } pub(crate) fn text(self, data: Cow<'_, str>) -> Result<(), Error> { let open_clipboard = self.clipboard?; - clipboard_win::raw::set_string(&data).map_err(|_| Error::Unknown { - description: "Could not place the specified text to the clipboard".into(), - })?; + clipboard_win::raw::set_string(&data) + .map_err(|_| Error::unknown("Could not place the specified text to the clipboard"))?; - add_clipboard_exclusions(open_clipboard, self.exclude_from_cloud, self.exclude_from_history) + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) } pub(crate) fn html(self, html: Cow<'_, str>, alt: Option>) -> Result<(), Error> { @@ -520,17 +692,21 @@ impl<'clipboard> Set<'clipboard> { Some(s) => s.into(), None => String::new(), }; - clipboard_win::raw::set_string(&alt).map_err(|_| Error::Unknown { - description: "Could not place the specified text to the clipboard".into(), - })?; + clipboard_win::raw::set_string(&alt) + .map_err(|_| Error::unknown("Could not place the specified text to the clipboard"))?; if let Some(format) = clipboard_win::register_format("HTML Format") { let html = wrap_html(&html); clipboard_win::raw::set_without_clear(format.get(), html.as_bytes()) - .map_err(|e| Error::Unknown { description: e.to_string() })?; + .map_err(|e| Error::unknown(e.to_string()))?; } - add_clipboard_exclusions(open_clipboard, self.exclude_from_cloud, self.exclude_from_history) + add_clipboard_exclusions( + open_clipboard, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) } #[cfg(feature = "image-data")] @@ -538,17 +714,90 @@ impl<'clipboard> Set<'clipboard> { let open_clipboard = self.clipboard?; if let Err(e) = clipboard_win::raw::empty() { - return Err(Error::Unknown { - description: format!("Failed to empty the clipboard. Got error code: {}", e), - }); + return Err(Error::unknown(format!( + "Failed to empty the clipboard. Got error code: {e}" + ))); }; - add_cf_dibv5(open_clipboard, image) + // XXX: The ordering of these functions is important, as some programs will grab the + // first format available. PNGs tend to have better compatibility on Windows, so it is set first. + image_data::add_png_file(&image)?; + image_data::add_cf_dibv5(open_clipboard, image)?; + Ok(()) + } + + pub(crate) fn file_list(self, file_list: &[impl AsRef]) -> Result<(), Error> { + const DROPFILES_HEADER_SIZE: usize = std::mem::size_of::(); + + let clipboard_assertion = self.clipboard?; + + // https://learn.microsoft.com/en-us/windows/win32/shell/clipboard#cf_hdrop + // CF_HDROP consists of an STGMEDIUM structure that contains a global memory object. + // The structure's hGlobal member points to the resulting data: + // | DROPFILES | FILENAME | NULL | ... | nth FILENAME | NULL | NULL | + let dropfiles = DROPFILES { + pFiles: DROPFILES_HEADER_SIZE as u32, + pt: POINT { x: 0, y: 0 }, + fNC: 0, + fWide: 1, + }; + + let mut data_len = DROPFILES_HEADER_SIZE; + + let paths: Vec<_> = file_list + .iter() + .filter_map(|path| { + to_final_path_wide(path.as_ref()).map(|wide| { + // Windows uses wchar_t which is 16 bit + data_len += wide.len() * std::mem::size_of::(); + wide + }) + }) + .collect(); + + if paths.is_empty() { + return Err(Error::ConversionFailure); + } + + // Add space for the final null character + data_len += std::mem::size_of::(); + + unsafe { + let h_global = global_alloc(data_len)?; + let data_ptr = global_lock(h_global)?; + + (data_ptr as *mut DROPFILES).write(dropfiles); + + let mut ptr = data_ptr.add(DROPFILES_HEADER_SIZE) as *mut u16; + + for wide_path in paths { + std::ptr::copy_nonoverlapping::(wide_path.as_ptr(), ptr, wide_path.len()); + ptr = ptr.add(wide_path.len()); + } + + // Write final null character + ptr.write(0); + + global_unlock_checked(h_global); + + if SetClipboardData(CF_HDROP.into(), h_global as HANDLE).failure() { + GlobalFree(h_global); + return Err(last_error("SetClipboardData failed with error")); + } + } + + add_clipboard_exclusions( + clipboard_assertion, + self.exclude_from_monitoring, + self.exclude_from_cloud, + self.exclude_from_history, + ) } } fn add_clipboard_exclusions( _open_clipboard: OpenClipboard<'_>, + exclude_from_monitoring: bool, exclude_from_cloud: bool, exclude_from_history: bool, ) -> Result<(), Error> { @@ -557,31 +806,36 @@ fn add_clipboard_exclusions( /// See https://docs.microsoft.com/en-us/windows/win32/dataxchg/clipboard-formats#cloud-clipboard-and-clipboard-history-formats const CLIPBOARD_EXCLUSION_DATA: &[u8] = &0u32.to_ne_bytes(); - // Clipboard exclusions are applied retroactively to the item that is currently in the clipboard. + // Clipboard exclusions are applied retroactively (we still have the clipboard lock) to the item that is currently in the clipboard. // See the MS docs on `CLIPBOARD_EXCLUSION_DATA` for specifics. Once the item is added to the clipboard, // tell Windows to remove it from cloud syncing and history. + if exclude_from_monitoring { + if let Some(format) = + clipboard_win::register_format("ExcludeClipboardContentFromMonitorProcessing") + { + // The documentation states "place any data on the clipboard in this format to prevent...", and using the zero bytes + // like the others for consistency works. + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA) + .map_err(|_| Error::unknown("Failed to exclude data from clipboard monitoring"))?; + } + } + if exclude_from_cloud { if let Some(format) = clipboard_win::register_format("CanUploadToCloudClipboard") { // We believe that it would be a logic error if this call failed, since we've validated the format is supported, // we still have full ownership of the clipboard and aren't moving it to another thread, and this is a well-documented operation. // Due to these reasons, `Error::Unknown` is used because we never expect the error path to be taken. - clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA).map_err( - |_| Error::Unknown { - description: "Failed to exclude data from cloud clipboard".into(), - }, - )?; + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA) + .map_err(|_| Error::unknown("Failed to exclude data from cloud clipboard"))?; } } if exclude_from_history { if let Some(format) = clipboard_win::register_format("CanIncludeInClipboardHistory") { // See above for reasoning about using `Error::Unknown`. - clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA).map_err( - |_| Error::Unknown { - description: "Failed to exclude data from clipboard history".into(), - }, - )?; + clipboard_win::raw::set_without_clear(format.get(), CLIPBOARD_EXCLUSION_DATA) + .map_err(|_| Error::unknown("Failed to exclude data from clipboard history"))?; } } @@ -590,6 +844,12 @@ fn add_clipboard_exclusions( /// Windows-specific extensions to the [`Set`](crate::Set) builder. pub trait SetExtWindows: private::Sealed { + /// Exclude the data which will be set on the clipboard from being processed + /// at all, either in the local clipboard history or getting uploaded to the cloud. + /// + /// If this is set, it is not recommended to call [exclude_from_cloud](SetExtWindows::exclude_from_cloud) or [exclude_from_history](SetExtWindows::exclude_from_history). + fn exclude_from_monitoring(self) -> Self; + /// Excludes the data which will be set on the clipboard from being uploaded to /// the Windows 10/11 [cloud clipboard]. /// @@ -604,6 +864,11 @@ pub trait SetExtWindows: private::Sealed { } impl SetExtWindows for crate::Set<'_> { + fn exclude_from_monitoring(mut self) -> Self { + self.platform.exclude_from_monitoring = true; + self + } + fn exclude_from_cloud(mut self) -> Self { self.platform.exclude_from_cloud = true; self @@ -626,8 +891,7 @@ impl<'clipboard> Clear<'clipboard> { pub(crate) fn clear(self) -> Result<(), Error> { let _clipboard_assertion = self.clipboard?; - clipboard_win::empty() - .map_err(|_| Error::Unknown { description: "failed to clear clipboard".into() }) + clipboard_win::empty().map_err(|_| Error::unknown("failed to clear clipboard")) } } @@ -650,38 +914,105 @@ fn wrap_html(ctn: &str) -> String { let n_end_frag = n_start_frag + ctn.len(); let n_end_html = n_end_frag + c_end_frag.len(); format!( - "{}{}{:010}{}{:010}{}{:010}{}{:010}{}{}{}", - h_version, - h_start_html, - n_start_html, - h_end_html, - n_end_html, - h_start_frag, - n_start_frag, - h_end_frag, - n_end_frag, - c_start_frag, - ctn, - c_end_frag, + "{h_version}{h_start_html}{n_start_html:010}{h_end_html}{n_end_html:010}{h_start_frag}{n_start_frag:010}{h_end_frag}{n_end_frag:010}{c_start_frag}{ctn}{c_end_frag}" ) } -#[cfg(all(test, feature = "image-data"))] -mod tests { - use super::{rgba_to_win, win_to_rgba}; +/// Given a file path attempt to open it and call GetFinalPathNameByHandleW, +/// on success return the final path as a NULL terminated u16 Vec +fn to_final_path_wide(p: &Path) -> Option> { + let file = std::fs::OpenOptions::new() + // No read or write permissions are necessary + .access_mode(0) + // This flag is so we can open directories too + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) + .open(p) + .ok()?; - const DATA: [u8; 16] = - [100, 100, 255, 100, 0, 0, 0, 255, 255, 100, 100, 255, 100, 255, 100, 100]; + fill_utf16_buf( + |buf, sz| unsafe { + GetFinalPathNameByHandleW(file.as_raw_handle() as HANDLE, buf, sz, VOLUME_NAME_DOS) + }, + |buf| { + let mut wide = Vec::with_capacity(buf.len() + 1); + wide.extend_from_slice(buf); + wide.push(0); - #[test] - fn check_win_to_rgba_conversion() { - let mut data = DATA; - unsafe { win_to_rgba(&mut data) }; - } + let hr = unsafe { PathCchStripPrefix(wide.as_mut_ptr(), wide.len()) }; + // On success truncate invalid data + if hr == S_OK { + if let Some(end) = wide.iter().position(|c| *c == 0) { + // Retain NULL character + wide.truncate(end + 1) + } + } + wide + }, + ) +} - #[test] - fn check_rgba_to_win_conversion() { - let mut data = DATA; - unsafe { rgba_to_win(&mut data) }; +/// +fn fill_utf16_buf(mut f1: F1, f2: F2) -> Option +where + F1: FnMut(*mut u16, u32) -> u32, + F2: FnOnce(&[u16]) -> T, +{ + // Start off with a stack buf but then spill over to the heap if we end up + // needing more space. + // + // This initial size also works around `GetFullPathNameW` returning + // incorrect size hints for some short paths: + // https://github.com/dylni/normpath/issues/5 + let mut stack_buf: [std::mem::MaybeUninit; 512] = [std::mem::MaybeUninit::uninit(); 512]; + let mut heap_buf: Vec> = Vec::new(); + unsafe { + let mut n = stack_buf.len(); + loop { + let buf = if n <= stack_buf.len() { + &mut stack_buf[..] + } else { + let extra = n - heap_buf.len(); + heap_buf.reserve(extra); + // We used `reserve` and not `reserve_exact`, so in theory we + // may have gotten more than requested. If so, we'd like to use + // it... so long as we won't cause overflow. + n = heap_buf.capacity().min(u32::MAX as usize); + // Safety: MaybeUninit does not need initialization + heap_buf.set_len(n); + &mut heap_buf[..] + }; + + // This function is typically called on windows API functions which + // will return the correct length of the string, but these functions + // also return the `0` on error. In some cases, however, the + // returned "correct length" may actually be 0! + // + // To handle this case we call `SetLastError` to reset it to 0 and + // then check it again if we get the "0 error value". If the "last + // error" is still 0 then we interpret it as a 0 length buffer and + // not an actual error. + windows_sys::Win32::Foundation::SetLastError(0); + let k = match f1(buf.as_mut_ptr().cast::(), n as u32) { + 0 if GetLastError() == 0 => 0, + 0 => return None, + n => n, + } as usize; + if k == n && GetLastError() == windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER + { + n = n.saturating_mul(2).min(u32::MAX as usize); + } else if k > n { + n = k; + } else if k == n { + // It is impossible to reach this point. + // On success, k is the returned string length excluding the null. + // On failure, k is the required buffer length including the null. + // Therefore k never equals n. + unreachable!(); + } else { + // Safety: First `k` values are initialized. + let slice = std::slice::from_raw_parts(buf.as_ptr() as *const u16, k); + return Some(f2(slice)); + } + } } }