Merge pull request #43 from NotAShelf/notashelf/push-rnnzunzyvynn

multicall: cleanup; match wl-copy/wl-paste interfaces more closely
This commit is contained in:
raf 2025-10-28 12:53:05 +03:00 committed by GitHub
commit 5a71640e5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1079 additions and 366 deletions

View file

@ -1,27 +1,26 @@
condense_wildcard_suffixes = true condense_wildcard_suffixes = true
doc_comment_code_block_width = 80 doc_comment_code_block_width = 80
edition = "2024" # Keep in sync with Cargo.toml. edition = "2024" # Keep in sync with Cargo.toml.
enum_discrim_align_threshold = 60 enum_discrim_align_threshold = 60
force_explicit_abi = false force_explicit_abi = false
force_multiline_blocks = true force_multiline_blocks = true
format_code_in_doc_comments = true format_code_in_doc_comments = true
format_macro_matchers = true format_macro_matchers = true
format_strings = true format_strings = true
group_imports = "StdExternalCrate" group_imports = "StdExternalCrate"
hex_literal_case = "Upper" hex_literal_case = "Upper"
imports_granularity = "Crate" imports_granularity = "Crate"
imports_layout = "HorizontalVertical" imports_layout = "HorizontalVertical"
inline_attribute_width = 60 inline_attribute_width = 60
match_block_trailing_comma = true match_block_trailing_comma = true
max_width = 80 max_width = 80
newline_style = "Unix" newline_style = "Unix"
normalize_comments = true normalize_comments = true
normalize_doc_attributes = true normalize_doc_attributes = true
overflow_delimited_expr = true overflow_delimited_expr = true
struct_field_align_threshold = 60 struct_field_align_threshold = 60
tab_spaces = 2 tab_spaces = 2
unstable_features = true unstable_features = true
use_field_init_shorthand = true use_field_init_shorthand = true
use_try_shorthand = true use_try_shorthand = true
wrap_comments = true wrap_comments = true

182
Cargo.lock generated
View file

@ -2,6 +2,21 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 version = 4
[[package]]
name = "addr2line"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b"
dependencies = [
"gimli",
]
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.3" version = "1.1.3"
@ -232,6 +247,21 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "backtrace"
version = "0.3.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6"
dependencies = [
"addr2line",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -353,6 +383,33 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
[[package]]
name = "color-eyre"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d"
dependencies = [
"backtrace",
"color-spantrace",
"eyre",
"indenter",
"once_cell",
"owo-colors",
"tracing-error",
]
[[package]]
name = "color-spantrace"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427"
dependencies = [
"once_cell",
"owo-colors",
"tracing-core",
"tracing-error",
]
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.4" version = "1.0.4"
@ -440,6 +497,17 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "ctrlc"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "881c5d0a13b2f1498e2306e82cbada78390e152d4b1378fb28a84f4dcd0dc4f3"
dependencies = [
"dispatch",
"nix",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@ -526,6 +594,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "dispatch"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]] [[package]]
name = "dispatch2" name = "dispatch2"
version = "0.3.0" version = "0.3.0"
@ -650,6 +724,16 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "eyre"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
dependencies = [
"indenter",
"once_cell",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@ -740,6 +824,12 @@ dependencies = [
"wasi 0.14.7+wasi-0.2.4", "wasi 0.14.7+wasi-0.2.4",
] ]
[[package]]
name = "gimli"
version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.5" version = "0.15.5"
@ -796,6 +886,12 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "indenter"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.11.4" version = "2.11.4"
@ -883,6 +979,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.177" version = "0.2.177"
@ -985,6 +1087,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.0.4" version = "1.0.4"
@ -1079,6 +1190,15 @@ dependencies = [
"objc2-core-foundation", "objc2-core-foundation",
] ]
[[package]]
name = "object"
version = "0.37.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@ -1117,6 +1237,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "owo-colors"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52"
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -1346,6 +1472,12 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "rustc-demangle"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace"
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "0.38.44" version = "0.38.44"
@ -1444,6 +1576,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
@ -1516,7 +1657,9 @@ dependencies = [
"base64", "base64",
"clap", "clap",
"clap-verbosity-flag", "clap-verbosity-flag",
"color-eyre",
"crossterm 0.29.0", "crossterm 0.29.0",
"ctrlc",
"dirs", "dirs",
"env_logger", "env_logger",
"imagesize", "imagesize",
@ -1533,7 +1676,6 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
"unicode-width 0.2.0", "unicode-width 0.2.0",
"wayland-client", "wayland-client",
"wayland-protocols",
"wayland-protocols-wlr", "wayland-protocols-wlr",
"wl-clipboard-rs", "wl-clipboard-rs",
] ]
@ -1628,6 +1770,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.44" version = "0.3.44"
@ -1706,6 +1857,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
dependencies = [ dependencies = [
"once_cell", "once_cell",
"valuable",
]
[[package]]
name = "tracing-error"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db"
dependencies = [
"tracing",
"tracing-subscriber",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
dependencies = [
"sharded-slab",
"thread_local",
"tracing-core",
] ]
[[package]] [[package]]
@ -1772,6 +1945,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]] [[package]]
name = "vcpkg" name = "vcpkg"
version = "0.2.15" version = "0.2.15"
@ -1822,6 +2001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d" checksum = "c66a47e840dc20793f2264eb4b3e4ecb4b75d91c0dd4af04b456128e0bdd449d"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"log",
"rustix 1.1.2", "rustix 1.1.2",
"wayland-backend", "wayland-backend",
"wayland-scanner", "wayland-scanner",

View file

@ -10,21 +10,23 @@ repository = "https://github.com/notashelf/stash"
rust-version = "1.85" rust-version = "1.85"
[[bin]] [[bin]]
name = "stash" # actual binary name for Nix, Cargo, etc. name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs" path = "src/main.rs"
[features] [features]
default = ["use-toplevel", "notifications"] default = ["use-toplevel", "notifications"]
use-toplevel = ["dep:wayland-client", "dep:wayland-protocols", "dep:wayland-protocols-wlr"] use-toplevel = ["dep:wayland-client", "dep:wayland-protocols-wlr"]
notifications = ["dep:notify-rust"] notifications = ["dep:notify-rust"]
[dependencies] [dependencies]
clap = { version = "4.5.48", features = ["derive", "env"] } clap = { version = "4.5.48", features = ["derive", "env"] }
clap-verbosity-flag = "3.0.4" clap-verbosity-flag = "3.0.4"
ctrlc = "3.5.0"
color-eyre = "0.6.5"
dirs = "6.0.0" dirs = "6.0.0"
imagesize = "0.14.0" imagesize = "0.14.0"
inquire = { default-features = false, version = "0.9.1", features = [ inquire = { default-features = false, version = "0.9.1", features = [
"crossterm", "crossterm",
] } ] }
log = "0.4.28" log = "0.4.28"
env_logger = "0.11.8" env_logger = "0.11.8"
@ -40,9 +42,8 @@ ratatui = "0.29.0"
crossterm = "0.29.0" crossterm = "0.29.0"
unicode-segmentation = "1.12.0" unicode-segmentation = "1.12.0"
unicode-width = "0.2.0" # FIXME: held back by ratatui unicode-width = "0.2.0" # FIXME: held back by ratatui
wayland-client = { version = "0.31.11", optional = true } wayland-client = { version = "0.31.11", features = ["log"], optional = true }
wayland-protocols = { version = "0.32.0", optional = true } wayland-protocols-wlr = { version = "0.3.9", default-features = false, optional = true }
wayland-protocols-wlr = { version = "0.3.9", optional = true }
notify-rust = { version = "4.11.7", optional = true } notify-rust = { version = "4.11.7", optional = true }
[profile.release] [profile.release]

122
README.md
View file

@ -20,8 +20,9 @@
</div> </div>
<div align="center"> <div align="center">
Wayland clipboard "manager" with fast persistent history and multi-media Lightweight Wayland clipboard "manager" with fast persistent history and
support. Stores and previews clipboard entries (text, images) on the command robust multi-media support. Stores and previews clipboard entries (text, images)
on the clipboard with a neat TUI and advanced scripting capabilities.
line. line.
</div> </div>
@ -35,8 +36,8 @@
## Features ## Features
Stash is a feature-rich, yet simple clipboard management utility with many Stash is a feature-rich, yet simple and lightweight clipboard management utility
features such as but not limited to: with many features such as but not necessarily limited to:
- Automatic MIME detection for stored entries - Automatic MIME detection for stored entries
- Fast persistent storage using SQLite - Fast persistent storage using SQLite
@ -64,7 +65,7 @@ you are on NixOS.
```nix ```nix
{ {
# Add Stash to your inputs like so # Add Stash to your inputs like so
inputs.stash.url = "github:notashelf/stash"; inputs.stash.url = "github:NotAShelf/stash";
outputs = { /* ... */ }; outputs = { /* ... */ };
} }
@ -86,10 +87,11 @@ in {
} }
``` ```
You can also run it one time with `nix run` If you want to give Stash a try before you switch to it, you may also run it one
time with `nix run`.
```sh ```sh
nix run github:notashelf/stash -- watch # start the watch daemon nix run github:NotAShelf/stash -- watch # start the watch daemon
``` ```
### Without Nix ### Without Nix
@ -98,12 +100,13 @@ nix run github:notashelf/stash -- watch # start the watch daemon
You can also install Stash on any of your systems _without_ using Nix. New You can also install Stash on any of your systems _without_ using Nix. New
releases are made when a version gets tagged, and are available under releases are made when a version gets tagged, and are available under
[GitHub Releases]. To install Stash on your system without Nix, eiter: [GitHub Releases]. To install Stash on your system without Nix, either:
- Download a tagged release from [GitHub Releases] for your platform and place - Download a tagged release from [GitHub Releases] for your platform and place
the binary in your `$PATH`. Instructions may differ based on your the binary in your `$PATH`. Instructions may differ based on your
distribution, but generally you want to download the built binary from distribution, but generally you want to download the built binary from
releases and put it somewhere like `/usr/bin`. releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on
your distribution.
- Build and install from source with Cargo: - Build and install from source with Cargo:
```bash ```bash
@ -112,16 +115,63 @@ releases are made when a version gets tagged, and are available under
## Usage ## Usage
Command interface is only slightly different from Cliphist. In most cases, it
will be as simple as replacing `cliphist` with `stash` in your commands, aliases
or scripts.
> [!NOTE] > [!NOTE]
> It is not a priority to provide 1:1 backwards compatibility with Cliphist. > It is not a priority to provide 1:1 backwards compatibility with Cliphist.
> While the interface is _almost_ identical, Stash chooses to build upon > While the interface is _almost_ identical, Stash chooses to build upon
> Cliphist's design and extend existing design choices. See > Cliphist's design and extend existing design choices. See
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. > [Migrating from Cliphist](#migrating-from-cliphist) for more details.
The command interface of Stash is _only slightly_ different from Cliphist. In
most cases, you may simply replace `cliphist` with `stash` and your commands,
aliases or scripts will continue to work as intended.
Some of the commands allow further fine-graining with flags such as `--type` or
`--format` to allow specific input and output specifiers. See `--help` for
individual subcommands if in doubt.
<!-- markdownlint-disable MD013 -->
```console
$ stash help
Wayland clipboard manager
Usage: stash [OPTIONS] [COMMAND]
Commands:
store Store clipboard contents
list List clipboard history
decode Decode and output clipboard entry by id
delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly
wipe Wipe all clipboard history
import Import clipboard data from stdin (default: TSV format)
watch Start a process to watch clipboard for changes and store automatically
help Print this message or the help of the given subcommand(s)
Options:
--max-items <MAX_ITEMS>
Maximum number of clipboard entries to keep [default: 18446744073709551615]
--max-dedupe-search <MAX_DEDUPE_SEARCH>
Number of recent entries to check for duplicates when storing new clipboard data [default: 20]
--preview-width <PREVIEW_WIDTH>
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
--db-path <DB_PATH>
Path to the `SQLite` clipboard database file
--excluded-apps <EXCLUDED_APPS>
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
--ask
Ask for confirmation before destructive operations
-v, --verbose...
Increase logging verbosity
-q, --quiet...
Decrease logging verbosity
-h, --help
Print help
-V, --version
Print version
```
<!-- markdownlint-enable MD013 -->
### Store an entry ### Store an entry
```bash ```bash
@ -134,18 +184,34 @@ echo "some clipboard text" | stash store
stash list stash list
``` ```
Stash list will list all entries in an interactive TUI that allows navigation
and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
enforce the output format with `stash list --format <tsv | json>`.
### Decode an entry by ID ### Decode an entry by ID
```bash ```bash
stash decode --input "1234" stash decode <input ID>
``` ```
> [!TIP]
> Decoding from dmenu-compatible tools:
>
> ```bash
> stash list | tofi | stash decode
> ```
### Delete entries matching a query ### Delete entries matching a query
```bash ```bash
stash delete --type query --arg "some text" stash delete --type [id | query] <text or ID>
``` ```
By default stash will try to guess the type of an entry, but this may not be
desirable for all users. If you wish to be explicit, pass `--type` to
`stash delete`.
### Delete multiple entries by ID (from a file or stdin) ### Delete multiple entries by ID (from a file or stdin)
```bash ```bash
@ -205,7 +271,8 @@ This can be configured in one of two ways. You can use the **environment
variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the
clipboard text matches the regex it will not be stored. This can be used for clipboard text matches the regex it will not be stored. This can be used for
trivial secrets such as but not limited to GitHub tokens or secrets that follow trivial secrets such as but not limited to GitHub tokens or secrets that follow
a rule, e.g. a prefix. a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or
similar but in some cases this might be a security flaw.
The safer alternative to this is using **Systemd LoadCrediental**. If Stash is The safer alternative to this is using **Systemd LoadCrediental**. If Stash is
running as a Systemd service, you can provide a regex pattern using a crediental running as a Systemd service, you can provide a regex pattern using a crediental
@ -228,6 +295,9 @@ logged.
> **Example regex to block common password patterns**: > **Example regex to block common password patterns**:
> >
> `(password|secret|api[_-]?key|token)[=: ]+[^\s]+` > `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
>
> For security reasons, you are recommended to use the regex only for generic
> tokens that follow a specific rule, for example a generic prefix or suffix.
#### Clipboard Filtering by Application Class #### Clipboard Filtering by Application Class
@ -327,6 +397,26 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
cliphist list --db ~/.cache/cliphist/db | stash import cliphist list --db ~/.cache/cliphist/db | stash import
``` ```
3. Stash provides its own implementation of `wl-copy` and `wl-paste` commands
backed by `wl-clipboard-rs`. Those implementations are backwards compatible
with `wl-clipboard`, and may be used as **drop-in** replacements. The default
build wrapper in `build.rs` links `stash` to `stash-copy` and `stash-paste`,
which are also available as `wl-copy` and `wl-paste` respectively. The Nix
package automatically links those to `$out/bin` for you, which means they are
installed by default but other package managers may need additional steps by
the packagers. While building from source, you may link
`target/release/stash` manually.
## Attributions
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the
[wl-clipboard-rs](https://github.com/YaLTeR/wl-clipboard-rs) crate. Stash is
powered by [several crates](./Cargo.toml), but none of them were as detrimental
in Stash's design process.
Additional thanks to my testers, who have tested earlier versions of Stash and
provided feedback. Thank you :)
## License ## License
This project is made available under Mozilla Public License (MPL) version 2.0. This project is made available under Mozilla Public License (MPL) version 2.0.

View file

@ -5,16 +5,6 @@ const MULTICALL_LINKS: &[&str] =
&["stash-copy", "stash-paste", "wl-copy", "wl-paste"]; &["stash-copy", "stash-paste", "wl-copy", "wl-paste"];
fn main() { fn main() {
// Only run on Unix-like systems
#[cfg(not(unix))]
{
println!(
"cargo:warning=Multicall symlinks are only supported on Unix-like \
systems."
);
return;
}
// OUT_DIR is something like .../target/debug/build/<pkg>/out // OUT_DIR is something like .../target/debug/build/<pkg>/out
// We want .../target/debug or .../target/release // We want .../target/debug or .../target/release
let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set");

View file

@ -2,7 +2,6 @@ use std::{
env, env,
io::{self, IsTerminal}, io::{self, IsTerminal},
path::PathBuf, path::PathBuf,
process,
}; };
use clap::{CommandFactory, Parser, Subcommand}; use clap::{CommandFactory, Parser, Subcommand};
@ -129,14 +128,27 @@ fn report_error<T>(
} }
#[allow(clippy::too_many_lines)] // whatever #[allow(clippy::too_many_lines)] // whatever
fn main() { fn main() -> color_eyre::eyre::Result<()> {
// Multicall dispatch: stash-copy, stash-paste, wl-copy, wl-paste // Check if we're being called as a multicall binary
if crate::multicall::multicall_dispatch() { let program_name = env::args().next().map(|s| {
// If handled, exit immediately PathBuf::from(s)
std::process::exit(0); .file_name()
.and_then(|name| name.to_str())
.unwrap_or("stash")
.to_string()
});
if let Some(ref name) = program_name {
if name == "wl-copy" || name == "stash-copy" {
crate::multicall::wl_copy::wl_copy_main()?;
return Ok(());
} else if name == "wl-paste" || name == "stash-paste" {
crate::multicall::wl_paste::wl_paste_main()?;
return Ok(());
}
} }
// If not multicall, proceed with normal CLI handling // Normal CLI handling
smol::block_on(async { smol::block_on(async {
let cli = Cli::parse(); let cli = Cli::parse();
env_logger::Builder::new() env_logger::Builder::new()
@ -151,24 +163,11 @@ fn main() {
}); });
if let Some(parent) = db_path.parent() { if let Some(parent) = db_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) { std::fs::create_dir_all(parent)?;
log::error!("Failed to create database directory: {e}");
process::exit(1);
}
} }
let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| { let conn = rusqlite::Connection::open(&db_path)?;
log::error!("Failed to open SQLite database: {e}"); let db = db::SqliteClipboardDb::new(conn)?;
process::exit(1);
});
let db = match db::SqliteClipboardDb::new(conn) {
Ok(db) => db,
Err(e) => {
log::error!("Failed to initialize SQLite database: {e}");
process::exit(1);
},
};
match cli.command { match cli.command {
Some(Command::Store) => { Some(Command::Store) => {
@ -345,12 +344,12 @@ fn main() {
&[], &[],
); );
}, },
None => { None => {
if let Err(e) = Cli::command().print_help() { Cli::command().print_help()?;
log::error!("Failed to print help: {e}");
}
println!(); println!();
}, },
} }
}); Ok(())
})
} }

View file

@ -1,282 +0,0 @@
use std::io::{self, Read, Write};
use clap::{ArgAction, Parser};
use wl_clipboard_rs::paste::{
ClipboardType,
Error,
MimeType,
Seat,
get_contents,
};
/// Dispatch multicall binary logic based on argv[0].
/// Returns true if a multicall command was handled and the process should exit.
pub fn multicall_dispatch() -> bool {
let argv0 = std::env::args().next().unwrap_or_default();
let base = std::path::Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
match base {
"stash-copy" | "wl-copy" => {
multicall_stash_copy();
true
},
"stash-paste" | "wl-paste" => {
multicall_stash_paste();
true
},
_ => false,
}
}
#[allow(clippy::too_many_lines)]
fn multicall_stash_copy() {
use clap::{ArgAction, Parser};
use wl_clipboard_rs::{
copy::{ClipboardType, MimeType, Options, ServeRequests, Source},
utils::{PrimarySelectionCheckError, is_primary_selection_supported},
};
#[derive(Parser, Debug)]
#[command(
name = "stash-copy",
about = "Copy clipboard contents on Wayland.",
version,
disable_help_subcommand = true
)]
#[allow(clippy::struct_excessive_bools)]
struct Args {
/// Serve only a single paste request and then exit
#[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)]
paste_once: bool,
/// Stay in the foreground instead of forking
#[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)]
foreground: bool,
/// Clear the clipboard instead of copying
#[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)]
clear: bool,
/// Use the \"primary\" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Use the regular clipboard
#[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)]
regular: bool,
/// Trim the trailing newline character before copying
#[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)]
trim_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Override the inferred MIME type for the content
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
/// Check if primary selection is supported and exit
#[arg(long = "check-primary", action = ArgAction::SetTrue)]
check_primary: bool,
/// Do not offer additional text mime types (stash extension)
#[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)]
omit_additional_text_mime_types: bool,
/// Number of paste requests to serve before exiting (stash extension)
#[arg(short = 'x', long = "serve-requests", hide = true)]
serve_requests: Option<usize>,
/// Text to copy (if not given, read from stdin)
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
text: Vec<String>,
}
let args = Args::parse();
if args.check_primary {
match is_primary_selection_supported() {
Ok(true) => {
log::info!("primary selection is supported.");
std::process::exit(0);
},
Ok(false) => {
log::info!("primary selection is NOT supported.");
std::process::exit(1);
},
Err(PrimarySelectionCheckError::NoSeats) => {
log::error!("could not determine: no seats available.");
std::process::exit(2);
},
Err(PrimarySelectionCheckError::MissingProtocol) => {
log::error!("data-control protocol not supported by compositor.");
std::process::exit(3);
},
Err(e) => {
log::error!("error checking primary selection support: {e}");
std::process::exit(4);
},
}
}
let clipboard = if args.primary {
ClipboardType::Primary
} else {
ClipboardType::Regular
};
let mime_type = if let Some(mt) = args.mime_type.as_deref() {
if mt == "text" || mt == "text/plain" {
MimeType::Text
} else if mt == "autodetect" {
MimeType::Autodetect
} else {
MimeType::Specific(mt.to_string())
}
} else {
MimeType::Autodetect
};
let mut input: Vec<u8> = Vec::new();
if args.text.is_empty() {
if let Err(e) = std::io::stdin().read_to_end(&mut input) {
eprintln!("failed to read stdin: {e}");
std::process::exit(1);
}
} else {
input = args.text.join(" ").into_bytes();
}
let mut opts = Options::new();
opts.clipboard(clipboard);
if args.trim_newline {
opts.trim_newline(true);
}
if args.foreground {
opts.foreground(true);
}
if let Some(seat) = args.seat.as_deref() {
log::debug!(
"'--seat' is not supported by stash (using default seat: {seat})"
);
}
if args.omit_additional_text_mime_types {
opts.omit_additional_text_mime_types(true);
}
// --paste-once overrides serve-requests
if args.paste_once {
opts.serve_requests(ServeRequests::Only(1));
} else if let Some(n) = args.serve_requests {
opts.serve_requests(ServeRequests::Only(n));
}
// --clear
if args.clear {
// Clear clipboard by setting empty contents
if let Err(e) = opts.copy(Source::Bytes(Vec::new().into()), mime_type) {
log::error!("failed to clear clipboard: {e}");
std::process::exit(1);
}
return;
}
if let Err(e) = opts.copy(Source::Bytes(input.into()), mime_type) {
log::error!("failed to copy to clipboard: {e}");
std::process::exit(1);
}
}
fn multicall_stash_paste() {
#[derive(Parser, Debug)]
#[command(
name = "stash-paste",
about = "Paste clipboard contents on Wayland.",
version,
disable_help_subcommand = true
)]
struct Args {
/// List the offered MIME types instead of pasting
#[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)]
list_types: bool,
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Do not append a newline character
#[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)]
no_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Request the given MIME type instead of inferring the MIME type
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
}
let args = Args::parse();
let clipboard = if args.primary {
ClipboardType::Primary
} else {
ClipboardType::Regular
};
if let Some(seat) = args.seat.as_deref() {
log::debug!(
"'--seat' is not supported by stash (using default seat: {seat})"
);
}
if args.list_types {
match get_contents(clipboard, Seat::Unspecified, MimeType::Text) {
Ok((_reader, available_types)) => {
log::info!("{available_types}");
std::process::exit(0);
},
Err(e) => {
log::error!("failed to list types: {e}");
std::process::exit(1);
},
}
}
let mime_type = match args.mime_type.as_deref() {
None | Some("text" | "autodetect") => MimeType::Text,
Some(other) => MimeType::Specific(other),
};
match get_contents(clipboard, Seat::Unspecified, mime_type) {
Ok((mut reader, _types)) => {
let mut out = io::stdout();
let mut buf = Vec::new();
match reader.read_to_end(&mut buf) {
Ok(n) => {
if n == 0 && args.no_newline {
std::process::exit(1);
}
let _ = out.write_all(&buf);
if !args.no_newline && !buf.ends_with(b"\n") {
let _ = out.write_all(b"\n");
}
},
Err(e) => {
log::error!("failed to read clipboard: {e}");
std::process::exit(1);
},
}
},
Err(Error::NoSeats) => {
log::error!("no seats available (is a Wayland compositor running?)");
std::process::exit(1);
},
Err(Error::ClipboardEmpty) => {
if args.no_newline {
std::process::exit(1);
}
},
Err(Error::NoMimeType) => {
log::error!("clipboard does not contain requested MIME type");
std::process::exit(1);
},
Err(e) => {
log::error!("clipboard error: {e}");
std::process::exit(1);
},
}
}

6
src/multicall/mod.rs Normal file
View file

@ -0,0 +1,6 @@
// Reference documentation:
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_copy.rs
pub mod wl_copy;
pub mod wl_paste;

276
src/multicall/wl_copy.rs Normal file
View file

@ -0,0 +1,276 @@
use std::io::{self, Read};
use clap::{ArgAction, Parser};
use color_eyre::eyre::{Context, Result, bail};
use wl_clipboard_rs::{
copy::{
ClipboardType as CopyClipboardType,
MimeType as CopyMimeType,
Options,
Seat as CopySeat,
ServeRequests,
Source,
},
utils::{PrimarySelectionCheckError, is_primary_selection_supported},
};
// Maximum clipboard content size to prevent memory exhaustion (100MB)
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
#[derive(Parser, Debug)]
#[command(
name = "wl-copy",
about = "Copy clipboard contents on Wayland.",
version
)]
#[allow(clippy::struct_excessive_bools)]
struct WlCopyArgs {
/// Serve only a single paste request and then exit
#[arg(short = 'o', long = "paste-once", action = ArgAction::SetTrue)]
paste_once: bool,
/// Stay in the foreground instead of forking
#[arg(short = 'f', long = "foreground", action = ArgAction::SetTrue)]
foreground: bool,
/// Clear the clipboard instead of copying
#[arg(short = 'c', long = "clear", action = ArgAction::SetTrue)]
clear: bool,
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Use the regular clipboard
#[arg(short = 'r', long = "regular", action = ArgAction::SetTrue)]
regular: bool,
/// Trim the trailing newline character before copying
#[arg(short = 'n', long = "trim-newline", action = ArgAction::SetTrue)]
trim_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Override the inferred MIME type for the content
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
/// Check if primary selection is supported and exit
#[arg(long = "check-primary", action = ArgAction::SetTrue)]
check_primary: bool,
/// Do not offer additional text mime types (stash extension)
#[arg(long = "omit-additional-text-mime-types", action = ArgAction::SetTrue, hide = true)]
omit_additional_text_mime_types: bool,
/// Number of paste requests to serve before exiting (stash extension)
#[arg(short = 'x', long = "serve-requests", hide = true)]
serve_requests: Option<usize>,
/// Text to copy (if not given, read from stdin)
#[arg(value_name = "TEXT TO COPY", action = ArgAction::Append)]
text: Vec<String>,
}
fn handle_check_primary() {
let exit_code = match is_primary_selection_supported() {
Ok(true) => {
log::info!("primary selection is supported.");
0
},
Ok(false) => {
log::info!("primary selection is NOT supported.");
1
},
Err(PrimarySelectionCheckError::NoSeats) => {
log::error!("could not determine: no seats available.");
2
},
Err(PrimarySelectionCheckError::MissingProtocol) => {
log::error!("data-control protocol not supported by compositor.");
3
},
Err(e) => {
log::error!("error checking primary selection support: {e}");
4
},
};
// Exit with the relevant code
std::process::exit(exit_code);
}
fn get_clipboard_type(primary: bool) -> CopyClipboardType {
if primary {
CopyClipboardType::Primary
} else {
CopyClipboardType::Regular
}
}
fn get_mime_type(mime_arg: Option<&str>) -> CopyMimeType {
match mime_arg {
Some("text" | "text/plain") => CopyMimeType::Text,
Some("autodetect") | None => CopyMimeType::Autodetect,
Some(specific) => CopyMimeType::Specific(specific.to_string()),
}
}
fn read_input_data(text_args: &[String]) -> Result<Vec<u8>> {
if text_args.is_empty() {
let mut buffer = Vec::new();
let mut stdin = io::stdin();
// Read with size limit to prevent memory exhaustion
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = stdin
.read(&mut temp_buffer)
.context("failed to read from stdin")?;
if bytes_read == 0 {
break;
}
if buffer.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"input exceeds maximum clipboard size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
buffer.extend_from_slice(&temp_buffer[..bytes_read]);
}
Ok(buffer)
} else {
let content = text_args.join(" ");
if content.len() > MAX_CLIPBOARD_SIZE {
bail!(
"input exceeds maximum clipboard size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
Ok(content.into_bytes())
}
}
fn configure_copy_options(
args: &WlCopyArgs,
clipboard: CopyClipboardType,
) -> Options {
let mut opts = Options::new();
opts.clipboard(clipboard);
opts.seat(
args
.seat
.as_deref()
.map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())),
);
if args.trim_newline {
opts.trim_newline(true);
}
if args.omit_additional_text_mime_types {
opts.omit_additional_text_mime_types(true);
}
if args.paste_once {
opts.serve_requests(ServeRequests::Only(1));
} else if let Some(n) = args.serve_requests {
opts.serve_requests(ServeRequests::Only(n));
}
opts
}
fn handle_clear_clipboard(
args: &WlCopyArgs,
clipboard: CopyClipboardType,
mime_type: CopyMimeType,
) -> Result<()> {
let mut opts = Options::new();
opts.clipboard(clipboard);
opts.seat(
args
.seat
.as_deref()
.map_or(CopySeat::All, |s| CopySeat::Specific(s.to_string())),
);
opts
.copy(Source::Bytes(Vec::new().into()), mime_type)
.context("failed to clear clipboard")?;
Ok(())
}
fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) {
// Use a simpler approach: serve in background thread instead of forking
// This avoids all the complexity and safety issues with fork()
let handle = std::thread::spawn(move || {
if let Err(e) = prepared_copy.serve() {
log::error!("background clipboard service failed: {e}");
}
});
// Give the background thread a moment to start
std::thread::sleep(std::time::Duration::from_millis(50));
log::debug!("clipboard service started in background thread");
// Detach the thread to allow it to run independently
// The thread will be cleaned up when it completes or when the process exits
std::mem::forget(handle);
}
pub fn wl_copy_main() -> Result<()> {
let args = WlCopyArgs::parse();
if args.check_primary {
handle_check_primary();
}
let clipboard = get_clipboard_type(args.primary);
let mime_type = get_mime_type(args.mime_type.as_deref());
// Handle clear operation
if args.clear {
handle_clear_clipboard(&args, clipboard, mime_type)?;
return Ok(());
}
// Read input data
let input =
read_input_data(&args.text).context("failed to read input data")?;
// Configure copy options
let opts = configure_copy_options(&args, clipboard);
// Handle foreground vs background mode
if args.foreground {
// Foreground mode: copy and serve in current process
opts
.copy(Source::Bytes(input.into()), mime_type)
.context("failed to copy to clipboard")?;
} else {
// Background mode: spawn child process to serve requests
// First prepare to copy to validate before spawning
let mut opts_fg = opts.clone();
opts_fg.foreground(true);
let prepared_copy = opts_fg
.prepare_copy(Source::Bytes(input.into()), mime_type)
.context("failed to prepare copy")?;
fork_and_serve(prepared_copy);
}
Ok(())
}

454
src/multicall/wl_paste.rs Normal file
View file

@ -0,0 +1,454 @@
// https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_data_device
// https://docs.rs/wl-clipboard-rs/latest/wl_clipboard_rs
// https://github.com/YaLTeR/wl-clipboard-rs/blob/master/wl-clipboard-rs-tools/src/bin/wl_paste.rs
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::{self, Read, Write},
process::{Command, Stdio},
sync::{Arc, Mutex},
thread,
time::{Duration, Instant},
};
use clap::{ArgAction, Parser};
use color_eyre::eyre::{Context, Result, bail};
use wl_clipboard_rs::paste::{
ClipboardType as PasteClipboardType,
Error as PasteError,
MimeType as PasteMimeType,
Seat as PasteSeat,
get_contents,
get_mime_types,
};
// Watch mode timing constants
const WATCH_POLL_INTERVAL_MS: u64 = 500;
const WATCH_DEBOUNCE_INTERVAL_MS: u64 = 1000;
// Maximum clipboard content size to prevent memory exhaustion (100MB)
const MAX_CLIPBOARD_SIZE: usize = 100 * 1024 * 1024;
#[derive(Parser, Debug)]
#[command(
name = "wl-paste",
about = "Paste clipboard contents on Wayland.",
version,
disable_help_subcommand = true
)]
struct WlPasteArgs {
/// List the offered MIME types instead of pasting
#[arg(short = 'l', long = "list-types", action = ArgAction::SetTrue)]
list_types: bool,
/// Use the "primary" clipboard
#[arg(short = 'p', long = "primary", action = ArgAction::SetTrue)]
primary: bool,
/// Do not append a newline character
#[arg(short = 'n', long = "no-newline", action = ArgAction::SetTrue)]
no_newline: bool,
/// Pick the seat to work with
#[arg(short = 's', long = "seat")]
seat: Option<String>,
/// Request the given MIME type instead of inferring the MIME type
#[arg(short = 't', long = "type")]
mime_type: Option<String>,
/// Enable verbose logging
#[arg(short = 'v', long = "verbose", action = ArgAction::Count)]
verbose: u8,
/// Watch for clipboard changes and run a command
#[arg(short = 'w', long = "watch")]
watch: Option<Vec<String>>,
}
fn get_paste_mime_type(mime_arg: Option<&str>) -> PasteMimeType {
match mime_arg {
None | Some("text" | "autodetect") => PasteMimeType::Text,
Some(other) => PasteMimeType::Specific(other),
}
}
fn handle_list_types(
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
match get_mime_types(clipboard, seat) {
Ok(types) => {
for mime_type in types {
println!("{mime_type}");
}
#[allow(clippy::needless_return)]
return Ok(());
},
Err(PasteError::NoSeats) => {
bail!("no seats available (is a Wayland compositor running?)");
},
Err(e) => {
bail!("failed to list types: {e}");
},
}
}
fn handle_watch_mode(
args: &WlPasteArgs,
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
let watch_args = args.watch.as_ref().unwrap();
if watch_args.is_empty() {
bail!("--watch requires a command to run");
}
log::info!("starting clipboard watch mode");
// Shared state for tracking last content and shutdown signal
let last_content_hash = Arc::new(Mutex::new(None::<u64>));
let shutdown = Arc::new(Mutex::new(false));
// Set up signal handler for graceful shutdown
let shutdown_clone = shutdown.clone();
ctrlc::set_handler(move || {
log::info!("received shutdown signal, stopping watch mode");
if let Ok(mut shutdown_guard) = shutdown_clone.lock() {
*shutdown_guard = true;
} else {
log::error!("failed to acquire shutdown lock in signal handler");
}
})
.context("failed to set signal handler")?;
let poll_interval = Duration::from_millis(WATCH_POLL_INTERVAL_MS);
let debounce_interval = Duration::from_millis(WATCH_DEBOUNCE_INTERVAL_MS);
let mut last_change_time = Instant::now();
loop {
// Check for shutdown signal
match shutdown.lock() {
Ok(shutdown_guard) => {
if *shutdown_guard {
log::info!("shutting down watch mode");
break Ok(());
}
},
Err(e) => {
log::error!("failed to acquire shutdown lock: {e}");
thread::sleep(poll_interval);
continue;
},
}
// Get current clipboard content
let current_hash = match get_clipboard_content_hash(clipboard, seat) {
Ok(hash) => hash,
Err(e) => {
log::error!("failed to get clipboard content hash: {e}");
thread::sleep(poll_interval);
continue;
},
};
// Check if content has changed
match last_content_hash.lock() {
Ok(mut last_hash_guard) => {
let changed = *last_hash_guard != Some(current_hash);
if changed {
let now = Instant::now();
// Debounce rapid changes
if now.duration_since(last_change_time) >= debounce_interval {
*last_hash_guard = Some(current_hash);
last_change_time = now;
drop(last_hash_guard); // Release lock before spawning command
log::info!("clipboard content changed, executing watch command");
// Execute the watch command
if let Err(e) = execute_watch_command(watch_args, clipboard, seat) {
log::error!("failed to execute watch command: {e}");
// Continue watching even if command fails
}
}
}
changed
},
Err(e) => {
log::error!("failed to acquire last_content_hash lock: {e}");
thread::sleep(poll_interval);
continue;
},
};
thread::sleep(poll_interval);
}
}
fn get_clipboard_content_hash(
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<u64> {
match get_contents(clipboard, seat, PasteMimeType::Text) {
Ok((mut reader, _types)) => {
let mut content = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard content")?;
if bytes_read == 0 {
break;
}
if content.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
content.extend_from_slice(&temp_buffer[..bytes_read]);
}
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
Ok(hasher.finish())
},
Err(PasteError::ClipboardEmpty) => {
Ok(0) // Empty clipboard has hash 0
},
Err(e) => bail!("clipboard error: {e}"),
}
}
/// Validate command name to prevent command injection
fn validate_command_name(cmd: &str) -> Result<()> {
if cmd.is_empty() {
bail!("command name cannot be empty");
}
// Reject commands with shell metacharacters or path traversal
if cmd.contains(|c| {
['|', '&', ';', '$', '`', '(', ')', '<', '>', '"', '\'', '\\'].contains(&c)
}) {
bail!("command contains invalid characters: {cmd}");
}
// Reject absolute paths and relative path traversal
if cmd.starts_with('/') || cmd.contains("..") {
bail!("command paths are not allowed: {cmd}");
}
Ok(())
}
/// Set environment variable safely with validation
fn set_clipboard_state_env(has_content: bool) -> Result<()> {
let value = if has_content { "data" } else { "nil" };
// Validate the environment variable value
if !matches!(value, "data" | "nil") {
bail!("invalid clipboard state value: {value}");
}
// Safe to set environment variable with validated, known-safe value
unsafe {
std::env::set_var("STASH_CLIPBOARD_STATE", value);
}
Ok(())
}
fn execute_watch_command(
watch_args: &[String],
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
if watch_args.is_empty() {
bail!("watch command cannot be empty");
}
// Validate command name for security
validate_command_name(&watch_args[0])?;
let mut cmd = Command::new(&watch_args[0]);
if watch_args.len() > 1 {
cmd.args(&watch_args[1..]);
}
// Get clipboard content and pipe it to the command
match get_contents(clipboard, seat, PasteMimeType::Text) {
Ok((mut reader, _types)) => {
let mut content = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard")?;
if bytes_read == 0 {
break;
}
if content.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
content.extend_from_slice(&temp_buffer[..bytes_read]);
}
// Set environment variable safely
set_clipboard_state_env(!content.is_empty())?;
// Spawn the command with the content as stdin
cmd.stdin(Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(stdin) = child.stdin.take() {
let mut stdin = stdin;
if let Err(e) = stdin.write_all(&content) {
bail!("failed to write to command stdin: {e}");
}
}
match child.wait() {
Ok(status) => {
if !status.success() {
log::warn!("watch command exited with status: {status}");
}
},
Err(e) => {
bail!("failed to wait for command: {e}");
},
}
},
Err(PasteError::ClipboardEmpty) => {
// Set environment variable safely
set_clipboard_state_env(false)?;
// Run command with /dev/null as stdin
cmd.stdin(Stdio::null());
match cmd.status() {
Ok(status) => {
if !status.success() {
log::warn!("watch command exited with status: {status}");
}
},
Err(e) => {
bail!("failed to run command: {e}");
},
}
},
Err(e) => {
bail!("clipboard error: {e}");
},
}
Ok(())
}
fn handle_regular_paste(
args: &WlPasteArgs,
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
let mime_type = get_paste_mime_type(args.mime_type.as_deref());
match get_contents(clipboard, seat, mime_type) {
Ok((mut reader, _types)) => {
let mut out = io::stdout();
let mut buf = Vec::new();
let mut temp_buffer = [0; 8192];
loop {
let bytes_read = reader
.read(&mut temp_buffer)
.context("failed to read clipboard")?;
if bytes_read == 0 {
break;
}
if buf.len() + bytes_read > MAX_CLIPBOARD_SIZE {
bail!(
"clipboard content exceeds maximum size of {} bytes",
MAX_CLIPBOARD_SIZE
);
}
buf.extend_from_slice(&temp_buffer[..bytes_read]);
}
if buf.is_empty() && args.no_newline {
bail!("no content available and --no-newline specified");
}
if let Err(e) = out.write_all(&buf) {
bail!("failed to write to stdout: {e}");
}
if !args.no_newline && !buf.ends_with(b"\n") {
if let Err(e) = out.write_all(b"\n") {
bail!("failed to write newline to stdout: {e}");
}
}
},
Err(PasteError::NoSeats) => {
bail!("no seats available (is a Wayland compositor running?)");
},
Err(PasteError::ClipboardEmpty) => {
if args.no_newline {
bail!("clipboard empty and --no-newline specified");
}
// Otherwise, exit successfully with no output
},
Err(PasteError::NoMimeType) => {
bail!("clipboard does not contain requested MIME type");
},
Err(e) => {
bail!("clipboard error: {e}");
},
}
Ok(())
}
pub fn wl_paste_main() -> Result<()> {
let args = WlPasteArgs::parse();
let clipboard = if args.primary {
PasteClipboardType::Primary
} else {
PasteClipboardType::Regular
};
let seat = args
.seat
.as_deref()
.map_or(PasteSeat::Unspecified, PasteSeat::Specific);
// Handle list-types option
if args.list_types {
handle_list_types(clipboard, seat)?;
return Ok(());
}
// Handle watch mode
if args.watch.is_some() {
handle_watch_mode(&args, clipboard, seat)?;
return Ok(());
}
// Regular paste mode
handle_regular_paste(&args, clipboard, seat)?;
Ok(())
}