mirror of
https://github.com/1Password/arboard.git
synced 2025-12-23 06:01:09 +00:00
Compare commits
83 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
223f4efc7a | ||
|
|
a3750c79a5 | ||
|
|
edcce2cd6b | ||
|
|
26a96a6199 | ||
|
|
7bdd1c1175 | ||
|
|
55c0b260c4 | ||
|
|
ff15a093d6 | ||
|
|
16ef18113f | ||
|
|
a3c64f9a93 | ||
|
|
e6008eaa91 | ||
|
|
17ef05ce13 | ||
|
|
ca2e80c409 | ||
|
|
6eed118532 | ||
|
|
a31adf444d | ||
|
|
d48f5ff0c7 | ||
|
|
90f8f526f4 | ||
|
|
4f9bff86dc | ||
|
|
380d2a691b | ||
|
|
68ea2074ac | ||
|
|
8f6bab7d48 | ||
|
|
b704da3cea | ||
|
|
1040043ca4 | ||
|
|
5f80bc1ddf | ||
|
|
b1e6720c3e | ||
|
|
6b0e47ac8a | ||
|
|
825026572a | ||
|
|
7ea1cf2caa | ||
|
|
b5e123032c | ||
|
|
91c33159b0 | ||
|
|
1dcc18b221 | ||
|
|
bbd06b4d57 | ||
|
|
bcb2954db5 | ||
|
|
a9c2d68c18 | ||
|
|
e824bc8324 | ||
|
|
543484f77c | ||
|
|
9d6a0b9b42 | ||
|
|
c474298e4b | ||
|
|
108cc38269 | ||
|
|
431702b657 | ||
|
|
4b91bfe93e | ||
|
|
e458e1a26c | ||
|
|
782b98c1e3 | ||
|
|
5350a8fb91 | ||
|
|
6b45272702 | ||
|
|
dd43f44ace | ||
|
|
ee39c47896 | ||
|
|
151e679ee5 | ||
|
|
610e29ba81 | ||
|
|
83740b7ab0 | ||
|
|
0bff1e07ea | ||
|
|
1cca83d7e5 | ||
|
|
b4646f6c5f | ||
|
|
e2846f9288 | ||
|
|
2f4b502508 | ||
|
|
6cf324cc44 | ||
|
|
eabb191df0 | ||
|
|
bc9fd24915 | ||
|
|
c5c798b3a1 | ||
|
|
bb2e898d83 | ||
|
|
e5d3df7017 | ||
|
|
f6fc4ea691 | ||
|
|
dc8a4bd4f2 | ||
|
|
f716441fe6 | ||
|
|
2d77eee554 | ||
|
|
d1ef0918de | ||
|
|
3f21b88baa | ||
|
|
77e0e078eb | ||
|
|
409bd98978 | ||
|
|
bd91f9c438 | ||
|
|
a648570ce9 | ||
|
|
0d6725d97f | ||
|
|
a100f2d77c | ||
|
|
1b8df75ee2 | ||
|
|
e3f54c3049 | ||
|
|
8c475cfd14 | ||
|
|
11e395c6d5 | ||
|
|
b2b0809632 | ||
|
|
f801e67e70 | ||
|
|
9bc6fcd3f8 | ||
|
|
d755392b56 | ||
|
|
48117d9688 | ||
|
|
1e7d475d6e | ||
|
|
5a627e9445 |
16 changed files with 2231 additions and 1372 deletions
95
.github/workflows/test.yml
vendored
95
.github/workflows/test.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
119
CHANGELOG.md
119
CHANGELOG.md
|
|
@ -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<Cow<'a>, 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.
|
||||
724
Cargo.lock
generated
724
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
85
Cargo.toml
85
Cargo.toml
|
|
@ -1,48 +1,91 @@
|
|||
[package]
|
||||
name = "arboard"
|
||||
version = "3.2.1"
|
||||
authors = ["Artur Kovacs <kovacs.artur.barnabas@gmail.com>", "Avi Weinstock <aweinstock314@gmail.com>", "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"
|
||||
|
|
|
|||
107
README.md
107
README.md
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
[](https://crates.io/crates/arboard)
|
||||
[](https://docs.rs/arboard)
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
<sup>
|
||||
Licensed under either of <a href="LICENSE-APACHE.txt">Apache License, Version
|
||||
2.0</a> or <a href="LICENSE-MIT.txt">MIT license</a> at your option.
|
||||
</sup>
|
||||
|
||||
<br>
|
||||
|
||||
<sub>
|
||||
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.
|
||||
</sub>
|
||||
|
||||
#### 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.".
|
||||
|
|
|
|||
|
|
@ -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<dyn Error + Send + Sync>> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
SimpleLogger::new().init().unwrap();
|
||||
env_logger::init();
|
||||
|
||||
if cfg!(target_os = "linux") {
|
||||
process::Command::new(env::current_exe()?)
|
||||
|
|
|
|||
|
|
@ -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}\"");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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#"<h1>Hello, World!</h1>
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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<M: Into<String>>(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]> {
|
||||
|
|
|
|||
110
src/lib.rs
110
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<Self, Error> {
|
||||
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<String, Error> {
|
||||
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<Cow<'a, str>>>(&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<Cow<'a, str>>>(
|
||||
&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<ImageData<'static>, 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<ImageData<'static>, Error> {
|
||||
self.platform.image()
|
||||
}
|
||||
|
||||
/// Completes the "get" operation by fetching HTML from the clipboard.
|
||||
pub fn html(self) -> Result<String, Error> {
|
||||
self.platform.html()
|
||||
}
|
||||
|
||||
/// Completes the "get" operation by fetching a list of file paths from the clipboard.
|
||||
pub fn file_list(self) -> Result<Vec<PathBuf>, 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<Path>]) -> 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 = "<b>hello</b> <i>world</i>!";
|
||||
|
||||
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}</body></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);
|
||||
|
|
|
|||
|
|
@ -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<E: std::fmt::Display>(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<Vec<u8>, 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<u8>) -> Vec<PathBuf> {
|
||||
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<Path>]) -> Result<String, Error> {
|
||||
// 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<String, Error> {
|
||||
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<Vec<PathBuf>, 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<Cow<'_, str>>) -> 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<Path>]) -> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<copy::ClipboardType> for LinuxClipboardKind {
|
||||
|
|
@ -44,52 +51,97 @@ impl TryInto<paste::ClipboardType> for LinuxClipboardKind {
|
|||
}
|
||||
}
|
||||
|
||||
fn add_clipboard_exclusions(exclude_from_history: bool, sources: &mut Vec<MimeSource>) {
|
||||
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<T, F: FnOnce(Vec<u8>) -> Result<T, Error>>(
|
||||
selection: LinuxClipboardKind,
|
||||
mime: paste::MimeType,
|
||||
into_requested_data: F,
|
||||
) -> Result<T, Error> {
|
||||
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<Self, Error> {
|
||||
// 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<String, Error> {
|
||||
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<String, Error> {
|
||||
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<Cow<'_, str>>,
|
||||
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<ImageData<'static>, 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<Vec<PathBuf>, 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<Path>],
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Self> {
|
||||
// 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<Option<Vec<ClipboardData>>>,
|
||||
/// 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<Option<Instant>>,
|
||||
/// 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<ClipboardData>,
|
||||
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<Inner>) -> Result<(), Box<dyn std::error::Error>>
|
|||
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<Inner>) -> Result<(), Box<dyn std::error::Error>>
|
|||
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<ClipboardData>) {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Cow<'_, str>>,
|
||||
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<Vec<PathBuf>> {
|
||||
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<Path>],
|
||||
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<Option<Instant>>| {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,4 +14,4 @@ pub use windows::*;
|
|||
#[cfg(target_os = "macos")]
|
||||
mod osx;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use osx::*;
|
||||
pub use osx::*;
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
width: usize,
|
||||
height: usize,
|
||||
) -> Result<Id<NSObject>, Box<dyn std::error::Error>> {
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone)]
|
||||
struct NSSize {
|
||||
width: CGFloat,
|
||||
height: CGFloat,
|
||||
) -> Result<Retained<objc2_app_kit::NSImage>, 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<c_void>, size: usize) {
|
||||
let data = data.cast::<u8>();
|
||||
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<u8>,
|
||||
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::<c_void>();
|
||||
|
||||
// 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<dyn CustomData>> = 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<NSObject> = 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<Object>,
|
||||
pasteboard: Retained<NSPasteboard>,
|
||||
}
|
||||
|
||||
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<Clipboard, Error> {
|
||||
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<Retained<NSPasteboard>> =
|
||||
unsafe { msg_send![NSPasteboard::class(), generalPasteboard] };
|
||||
|
||||
if !pasteboard.is_null() {
|
||||
// SAFETY: `generalPasteboard` is not null and a valid object pointer.
|
||||
let pasteboard: Id<Object> = 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<String, Error> {
|
||||
// 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<Option<ClipboardContent>, Box<dyn std::error::Error>> {
|
||||
|
|
@ -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<String, Error> {
|
||||
// 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<NonNull<NSArray<NSObject>>> =
|
||||
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<NonNull<NSString>> =
|
||||
unsafe { msg_send![item, stringForType:NSPasteboardTypeString] };
|
||||
|
||||
match maybe_str {
|
||||
Some(string) => {
|
||||
let string: Id<NSString, Owned> = 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<String, Error> {
|
||||
unsafe { self.clipboard.string_from_type(NSPasteboardTypeHTML) }
|
||||
}
|
||||
|
||||
#[cfg(feature = "image-data")]
|
||||
pub(crate) fn image(self) -> Result<ImageData<'static>, 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<NonNull<NSData>> =
|
||||
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<NSData> = 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<Vec<PathBuf>, 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::<NSURL>().ok().and_then(|url| {
|
||||
unsafe { url.path() }.map(|p| PathBuf::from(p.to_string()))
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.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><head><meta http-equiv="content-type" content="text/html; charset=utf-8"></head><body>{}</body></html>"#,
|
||||
html
|
||||
r#"<html><head><meta http-equiv="content-type" content="text/html; charset=utf-8"></head><body>{html}</body></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<NSObject, Owned>> = 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<Path>]) -> 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::<Vec<_>>();
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue