Compare commits

...

200 commits

Author SHA1 Message Date
raf
3c61cc19f6
Merge pull request #86 from NotAShelf/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-04-12 23:07:28 +03:00
dependabot[bot]
cd692ba002
build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-12 20:00:34 +00:00
ac7fbe293b
build: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If7985aa26f98a6aac1a994118df886046a6a6964
2026-04-12 22:59:45 +03:00
84cf1b46ad
stash: add a note about Clap's multicall handling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4aec7f38ab24a6cd6310630f2169690c6a6a6964
2026-04-12 22:59:45 +03:00
81683ded03
nix: bump inputs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4ae530fc33a1d4033600801193a2566d6a6a6964
2026-04-12 22:59:44 +03:00
20504a6e8b
ci: update flake inputs with dependabot; add cooldown to Rust deps
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iac735278f32f323106314eb9d94159f06a6a6964
2026-04-12 22:59:43 +03:00
raf
f139bda7b2
Merge pull request #82 from fxzzi/dfldsjfslkjf
nix: don't source old build script
2026-04-03 22:13:52 +03:00
Fazzi
32cf1936b6 nix: don't source old build script 2026-04-03 20:08:31 +01:00
b0ee7f59a3
commands: deprecate plain wipe command in favor of db wipe
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I62dbcc00b6b79f160318f9704fab001b6a6a6964
2026-04-03 14:46:08 +03:00
75ca501e29
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibecde757e509c21ad612fc9b8e0fb5876a6a6964
2026-04-03 14:12:02 +03:00
5cb6c84f08
docs: document clipboard persistence opt-in behaviour
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie0830d547ba0e4fcbd620290b3d314b16a6a6964
2026-04-03 14:12:01 +03:00
da9bf5ea3e
treewide: make logging format more consistent; make clipboard persistence opt-in
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9092f93c29fcbe99c90483875f4acd0c6a6a6964
2026-04-03 14:12:00 +03:00
9702e67599
build: get rid of the overzealous build script; leave symlinking to packagers
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I39c590f0a703ab71d3cb5a8df9b095a46a6a6964
2026-04-03 14:11:59 +03:00
77ac70f0d3 db/nonblocking: add test-only imports for the Fnv1aHasher
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I66effd259c6654bd4efac2f4e6bc4e176a6a6964
2026-04-01 16:25:21 +03:00
d643376cd7 stash: deduplicate Fnv1aHasher; add derive for u64 wrapper
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic2886815721f6eefc66a8ddacd44fb286a6a6964
2026-04-01 16:23:58 +03:00
raf
a2a609f07d
Merge pull request #80 from NotAShelf/notashelf/push-yvkonkrnonvs
various: implement clipboard persistence
2026-04-01 08:46:30 +03:00
d9bee33aba
stash: consolidate confirmation prompts; install color_eyre hook
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7fb4ba67098f897849fc9b317c7fde646a6a6964
2026-03-31 15:25:09 +03:00
030be21ea5
clipboard: persist clipboard contents after source application closes
When the source application closes, the forked child continues serving
clipboard data so it remains available for paste operations.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I14fbcf8cbc47c40bfa1da7f8b09245936a6a6964
2026-03-31 11:50:47 +03:00
fe86356399
wayland: use arc-swap over Mutex for FOCUSED_APP for better concurrency
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id6b40d5c533c35dda5bce7b852b836f26a6a6964
2026-03-31 11:50:46 +03:00
raf
0c57f9b4bd
Merge pull request #76 from NotAShelf/dependabot/github_actions/cachix/cachix-action-17
build(deps): bump cachix/cachix-action from 16 to 17
2026-03-31 09:33:42 +03:00
aabf40ac6e
build: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7a974572e4e36c9013e5c1c808677eaf6a6a6964
2026-03-31 09:28:59 +03:00
dependabot[bot]
909bb53afa
build(deps): bump cachix/cachix-action from 16 to 17
Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 16 to 17.
- [Release notes](https://github.com/cachix/cachix-action/releases)
- [Commits](https://github.com/cachix/cachix-action/compare/v16...v17)

---
updated-dependencies:
- dependency-name: cachix/cachix-action
  dependency-version: '17'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-19 14:55:19 +00:00
raf
208359dc0c
Merge pull request #72 from NotAShelf/dependabot/cargo/libc-0.2.183
build(deps): bump libc from 0.2.182 to 0.2.183
2026-03-09 19:55:07 +03:00
dependabot[bot]
3faadd709f
build(deps): bump libc from 0.2.182 to 0.2.183
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.182 to 0.2.183.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.183/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.182...0.2.183)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.183
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-09 16:36:04 +00:00
raf
8754921106
Merge pull request #70 from NotAShelf/dependabot/cargo/ctrlc-3.5.2
build(deps): bump ctrlc from 3.5.1 to 3.5.2
2026-03-06 16:55:56 +03:00
raf
be6cde092a
Merge pull request #71 from NotAShelf/notashelf/push-nnnqqrzkpywp
stash/db: general cleanup; async db ops for `watch` & deterministic hashing
2026-03-06 16:55:43 +03:00
b1f43bdf7f
db: replace \CHECKED\ atomic flag with pattern-keyed regex cache
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9d5fa5212c5418ce6bca02d05149e1356a6a6964
2026-03-05 16:07:49 +03:00
373affabee
db: improve content hashing; cache only positive scan result
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8035bf1dcd598a992762b9c714253406a6a6964
2026-03-05 15:07:32 +03:00
0865a1f139
commands/list: debounce for rapid copy operations
Tracks the entry ID currently being copied in `TuiState` to prevent
concurrent `copy_entry()` calls on the same entity. Otherwise we hit a
race condition. Fun!


Track the entry ID currently being copied in TuiState to prevent
concurrent copy_entry() calls on the same entry. Fixes database
race conditions when users trigger copy commands in rapid succession.



Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If8e8fe56bf6dc35960e47decf59636116a6a6964
2026-03-05 15:07:31 +03:00
cf5b1e8205
db: tests for determinism & async ops
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2591e607a945c0aaa28a75247fc638436a6a6964
2026-03-05 15:07:30 +03:00
95bf1766ce
stash: async db operations; make hashes deterministic
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iccc9980fa13a752e0e6c9fb630c28ba96a6a6964
2026-03-05 15:07:24 +03:00
7184c8b682
db: consolidate duplicated SQL queries
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8b6889d1e420865d0a8d3b8da916d8086a6a6964
2026-03-05 12:56:56 +03:00
ffdc13e8f5
commands/list: allow printing in reversed order with --reverse
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I305cfdc68d877dc5d5083a76dccc62db6a6a6964
2026-03-05 09:34:37 +03:00
dependabot[bot]
5e0599dc71
build(deps): bump ctrlc from 3.5.1 to 3.5.2
Bumps [ctrlc](https://github.com/Detegr/rust-ctrlc) from 3.5.1 to 3.5.2.
- [Release notes](https://github.com/Detegr/rust-ctrlc/releases)
- [Commits](https://github.com/Detegr/rust-ctrlc/compare/3.5.1...3.5.2)

---
updated-dependencies:
- dependency-name: ctrlc
  dependency-version: 3.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 16:28:36 +00:00
181edcefb1
db: add MIME sniffing for binary clipboard previews
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I70416269dd40496758b6e5431e77a9456a6a6964
2026-02-27 12:15:10 +03:00
ebf46de99d
docs: add installation instructions for crates.io
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib9a3fc7ee21324707d046d52a24b50596a6a6964
2026-02-27 10:34:38 +03:00
ba2e29d5b7
docs: fix HTML formatting; mention Cliphist's features
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I92716daef01c00bbe8e75426c3662fbb6a6a6964
2026-02-27 10:09:04 +03:00
3a14860ae1
various: validate lower and upper boundaries before storing; add CLI flags
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6484f9579a8799d952b15adcb47c8eec6a6a6964
2026-02-27 07:59:28 +03:00
02ba05dc95
db: add new error variants for entries below minimum and above maximum sizes
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icba2920cfef0ffb0ce6435ab6d7809166a6a6964
2026-02-27 07:58:46 +03:00
469fccbef6
chore: release v0.3.6
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2adaf9944a4572dcd15157f32b52eec26a6a6964
2026-02-26 16:24:42 +03:00
117e9d11ef
docs: add cliphist to attributions section; add motivation section
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia3da5b4dc3aeeb98eafc77173ae592596a6a6964
2026-02-26 16:24:40 +03:00
raf
23bf9d4044
Merge pull request #68 from NotAShelf/notashelf/push-vqzzqprquvxk
commands/list: full TUI rewrite for better perf
2026-02-26 16:15:40 +03:00
b850a54f7b
commands/list: implement clipboard history search
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I57f00cbd9d02b1981cf3ea5dc908e72c6a6a6964
2026-02-26 12:06:18 +03:00
88c1f0f158
commands/list: full TUI rewrite for better perf
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I49009a89542fdeeea31d3755108b53d06a6a6964
2026-02-26 12:06:17 +03:00
0215ebeb6c
chore: recursively bump time dep
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I57471a3c88a4cfe2d267f0fa8ceb59946a6a6964
2026-02-26 11:09:51 +03:00
raf
ce98b6db09
Merge pull request #67 from NotAShelf/notashelf/push-otlvvpomrtom
meta: bump deps & MSRV; allow disabling multicall bins
2026-02-26 09:38:49 +03:00
4d58cae50d
nix: add platforms to meta; allow overriding symlink behaviour
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib6e44abd86bd0e58f290b456680a97236a6a6964
2026-02-26 09:12:27 +03:00
2e3c73957a
meta: allow disabling symlinks in build script via env vars
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I07f5d565d26ca527d413edf69857539e6a6a6964
2026-02-26 09:12:26 +03:00
d367728b39
chore: set MSRV to 1.91.0
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Iadde6dfe7e79a365edf4d664b941c0776a6a6964
2026-02-26 09:12:25 +03:00
2edecf4c17
chore: format with taplo
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I942883a08eccc5decd38a6865b3451496a6a6964
2026-02-26 09:12:24 +03:00
134da06fd0
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I53a5279d1c3e74ae54e2f32a800f83766a6a6964
2026-02-26 09:12:06 +03:00
2227ef7e89
chore: format Cargo manifest with Taplo; v0.3.5
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Id35b12bba16b0e181bb4536154259b5a6a6a6964
2026-02-01 18:12:53 +03:00
2e086800d0
chore: format TOML with Taplo
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2ecc8923946ace5288a1c45ca202cb956a6a6964
2026-02-01 18:12:52 +03:00
cff9f7bbba
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib0445df9b8e5f0d4aabfcd4ff1bc27f16a6a6964
2026-02-01 18:12:51 +03:00
raf
23bb89e3ea
Merge pull request #61 from NotAShelf/notashelf/push-wompwwqskzwu
mime: refactor mime detection to separate module; streamline
2026-02-01 16:45:41 +03:00
9afbe9ceca
watch: deprioritize text/html in MIME negotiation
Firefox and Electron apps offer `text/html` first when copying images,
which causes stash to store the HTML wrapper (`<img src="...">`) instead
of the actual image data, which is what we want. We handicap, i.e.,
deprioritize `text/html` in the "any" preference mode and prefer
`image/*` types first, then any non-html type. 

This sounds a little illogical, but in user will almost always prefer
the image itself rather than the text representation. So it's intuitive.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6bd5969344893e15226c27071442475f6a6a6964
2026-02-01 14:55:31 +03:00
3fd48896c1
watch: respect source MIME type order in clipboard polling
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3da2e187276611579f3686acb20aacf36a6a6964
2026-02-01 11:55:18 +03:00
b4dd704961
db: add an in-memory test helper
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I22cc10df47265fa4d08d5c03cadbe9c56a6a6964
2026-02-01 11:55:17 +03:00
bb8e882565
mime: expand test coverage
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3f17b98ad68f17ebcf9554e5e88f62676a6a6964
2026-02-01 11:55:02 +03:00
5c8591b2e5
docs: mention MIME preference usage in README
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I3bda3397f0350f27523b419bd079f8756a6a6964
2026-01-23 23:13:02 +03:00
ff2f272055
mime: refactor mime detection to separate module; streamline
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I489054d2537a4c0de32d79f793478c206a6a6964
2026-01-23 23:13:01 +03:00
raf
ded38723d4
Merge pull request #60 from NotAShelf/notashelf/push-wvyzsrrzyrum
add auto-expiry mechanism to `stash watch`
2026-01-23 21:10:41 +03:00
e185ecd32a
docs: document entry expiry features for stash watch & db cmds
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I60fe5afdb6e903b96023ca420bb7902d6a6a6964
2026-01-22 19:40:41 +03:00
b00e9b5f3a
watch: clear clipboard when expired entry content matches current clipboard
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4bede5db16cea993ed8e8591e8d198d56a6a6964
2026-01-22 18:27:24 +03:00
5731fb08a5
cli: add db subcommand
Adds a `db` subcommand with `DbAction` for three new database
operations: wipe, vacuum and stats. We can extend this database later,
but this is a very good start for now and plays nicely with the
`--expired` flag. This soft-deprecates `stash wipe` in favor of a `stash
db wipe` with the addition of a new `--expired` flag that wipes the
expired entries only. 

The `list` subcommand has also been refactored to allow for a similar
`--expired` flag that lists only expired entries.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I34107880185d231d207b0dab7782d5d96a6a6964
2026-01-22 16:56:44 +03:00
2e555ee043
commands/list: add include_expired parameter for filtering
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ia1ab13345cfa5e2cf9a92f8b32a6a9826a6a6964
2026-01-22 16:56:43 +03:00
b070d4d93d
watch: implement soft-delete behaviour for expired entries
The previous `--expire-after` flag behave more like *delete* after
rather than *expire*. This fixes that, and changes the behaviour to
excluding expired entries from list commands and already-marked expired
entries from expiration queue. Updates log messages accordingly.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ib162dff3a76e23edcdfbd1af13b01b916a6a6964
2026-01-22 16:56:42 +03:00
d40b547c07
db: add is_expired column and implement vacuum/stats commands
Migrates schema to v5; `is_expired` column is added with partial index
and `include_expired` parameter to `list_entries()` and `list_json()`
methods. Also adds `vacuum()` and `stats()` methods for SQlite
"administration", and removes `next_sequence()` from trait and impl.

This has been a valuable addition to stash, as the database is now *less
abstract* in the sense that user is made aware of its presence (stash
wipe -> stash db wipe) and can modify it.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icfab67753d7f18e3798c0a930b16d05e6a6a6964
2026-01-22 16:56:41 +03:00
f4936e56ff
cli: add --expire-after flag to watch and --expired flag to list
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I833e7bfaecb5e3254d2ea16f2b880e246a6a6964
2026-01-22 16:56:40 +03:00
dd7a55c760
watch: implement expiration queue w/ sub-second precision
This adds a Neg wrapper struct for min-heap behaviour on BinaryHeap
which has proven *really* valuable. Also modify `watch()` to take the
`expire_after` argument for various new features. See my previous commit
for what is actually new.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8705d404eae5d93ad48f738a24f698196a6a6964
2026-01-22 16:56:39 +03:00
71fc1ff40f
db: add `expires_at column and expiration management methods
Schema v4: add expires_at REAL column with partial index for NULL values

Other relevant methods that were added:

- `now()` for Unix timestamp with sub-second precision
- `cleanup_expired()` to remove all expired entries
- `get_expired_entries()` for for diagnostic output (`stash list --expired`)
- `get_next_expiration()` for heap initialization
- `set_expiration()` to update expiration timestamp

This feature has proven larger than I had anticipated (and hoped) but
that's the reality of dealing with databases. Some of the methods are
slightly redundant but it helps keep tracing the code manageable and
semantically correct. We'll probably not regret those later. Probably.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie9e5b0767673e74389b8e59c466afd946a6a6964
2026-01-22 16:56:38 +03:00
bb1c5dc50b
chore: release v0.3.4
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie637c425e0d13985e3025a2ebaac41916a6a6964
2026-01-22 14:04:20 +03:00
441334a250
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8ef1f283622f50d93ba9d581699e272b6a6a6964
2026-01-22 14:04:19 +03:00
raf
4ab9ce4a71
Merge pull request #58 from NotAShelf/notashelf/push-oksprvxpxpxt
various: improve robustness of entry tracking in database
2026-01-22 13:53:50 +03:00
047445b143
db: distinguish HEIC from HEIF in mime type detection
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I1a25c6d30fde6b4cc33c2a1666b2e1606a6a6964
2026-01-22 13:48:30 +03:00
3d22a271bc
chore: add tempfile dependency for tests
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ibf7a842a2a26f83e8adaf1123386306b6a6a6964
2026-01-22 13:41:59 +03:00
c65073e0d1
db: rewrite migration with transactional schema versioning
This makes Stash's database handler a bit more robust. The changes
started as me trying to add an entry expiry, but I've realized that the
database system is a little fragile and it assumed the database does not
change, ever. Well that's not true, it does change and when it does
there's a chance that everything implodes.

We now wrap migrations in transaction for atomicity and track version
via PRAGMA user_version (0 -> 3). We also check column existence before
ALTER TABLE and use `last_insert_rowid()` instead of `next_sequence()`.

Last but not least, a bunch of regression tests have been added to the
database system because I'd rather not discover regressions in
production.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifeab42b0816a5161d736767cb82065346a6a6964
2026-01-22 13:41:58 +03:00
3165543580
commands: prevent usize underflow when navigating empty entry list
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0432dcc88b22226772f6bb6e05cc64d36a6a6964
2026-01-22 13:41:57 +03:00
20b6a12461
stash: make db module public for test visibility
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5f75e6515114e7479a3fe63771a4e7fe6a6a6964
2026-01-22 13:41:56 +03:00
dca7cca455
nix: add cargo-nextest to devshell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2266c2f3fccff23fa3950f8fac3365f36a6a6964
2026-01-22 13:41:55 +03:00
59423f9ae4
list: add content_hash and last_accessed tracking with de-duplication
Adds a `content_hash` column and index for deduplication, and a
`last_accessed` column & index for time tracking. We now de-duplicate on
copy by not copying if present, but instead bubbling up matching entry.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Icbcdbd6ac28bbb21324785cae30911f96a6a6964
2026-01-22 13:41:54 +03:00
raf
65a8eebd46
Merge pull request #57 from NotAShelf/notashelf/push-royltkszywmz
multicall: prevent newline corruption of binary data in wl-copy
2025-12-23 10:29:12 +03:00
f2274aa524
multicall: auto-select MIME type more intelligently when not specified
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Idfd5ab25079161d694bda429e70500a16a6a6964
2025-12-23 10:11:28 +03:00
bbfe583423
multicall: prevent newline corruption of binary data in wl-copy
Previously we unconditionally appended a newline to all clipboard
contents, which ended up corrupting binary files like PNG images when
using shell redirection (e.g., `wl-paste > file.png`). Now we
intelligently (in quotes) detect content type via MIME type and only
append newlines to text-based content such as `text/*`,
`application/json` and so on. Binary data on another hand is written
exactly as it is. Falls back to UTF-8 validation when MIME type is
unavailable.

On paper this is also fully backwards compatible; text content still
gets newline by default *unless* the `--no-newline` flag is used.

Fixes #52

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8b1e6f7013d081150be761820cafd1926a6a6964
2025-12-23 09:41:19 +03:00
raf
1f0312b2f6
Merge pull request #56 from NotAShelf/notashelf/push-umuwyuqntslp
various: bump dependencies
2025-12-22 17:14:53 +03:00
f6818c9e6f
chore: release v0.3.3
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5d53159c67ea83260c213ea93114b3d96a6a6964
2025-12-22 16:59:13 +03:00
c2182d21dc
chore: bump dependencies; fix lifetime warnings for Rust 1.90+
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If755ceefb970311c7660118cb2019c2c6a6a6964
2025-12-22 16:59:12 +03:00
8a25a03486
flake: bump nixpkgs
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4572c7075e11282280d514ffde4361586a6a6964
2025-12-22 16:59:11 +03:00
raf
f838365314
Merge pull request #49 from NotAShelf/notashelf/push-vootvqpuytyv
multicall: go back to forking solution
2025-11-25 10:08:24 +03:00
raf
bb88c89a0f
Merge pull request #50 from NotAShelf/dependabot/github_actions/actions/checkout-6
build(deps): bump actions/checkout from 5 to 6
2025-11-21 21:03:35 +03:00
dependabot[bot]
c8ead9a308
build(deps): bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:26:17 +00:00
a68946d54d
various: fix clippy lints
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I4bb649a5161460d8794dc5c93baa6cc46a6a6964
2025-11-13 00:05:52 +03:00
2d8ccf2a4f
multicall: go back to forking solution
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2a24a3c7efc41fc45c675fd98e08782e6a6a6964
2025-11-13 00:05:48 +03:00
96089f364b
docs: fix typo
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9b29db50afe2f2768bd4bc260bc5aaf96a6a6964
2025-10-28 13:03:00 +03:00
61ff65e9e8
stash: make log messages lowercase
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I45a9055b6bc3bfbf2179627470d0cedd6a6a6964
2025-10-28 13:02:59 +03:00
raf
b71801f7df
Merge pull request #44 from NotAShelf/dependabot/cargo/clap-4.5.50
build(deps): bump clap from 4.5.49 to 4.5.50
2025-10-28 13:03:05 +03:00
dependabot[bot]
9fc118a924
build(deps): bump clap from 4.5.49 to 4.5.50
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.49 to 4.5.50.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.49...clap_complete-v4.5.50)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.50
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-28 09:54:29 +00:00
raf
5a71640e5f
Merge pull request #43 from NotAShelf/notashelf/push-rnnzunzyvynn
multicall: cleanup; match wl-copy/wl-paste interfaces more closely
2025-10-28 12:53:05 +03:00
d59ac77b9f
stash: utilize clap for multicall functionality; simplify CLI handler
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I84d9f46bb9bba0e893aa4f99d6ff48f76a6a6964
2025-10-28 12:40:17 +03:00
43a3aae496
docs: add attributions section; detail remaining sections
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ice462ee8fc34e375a01940b6b013f5496a6a6964
2025-10-28 12:40:13 +03:00
c95d9a4567
chore: remove unused deps; format with taplo
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: If575be0b2c6f1f8b8eac6cacaa2784606a6a6964
2025-10-27 11:11:14 +03:00
78acc38044
multicall: cleanup; modularize
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I658f22fdf983777354a5beb32df631916a6a6964
2025-10-27 11:11:13 +03:00
e94d931e67
chore: remove redundant unix check in build wrapper
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I174857e67f2e400d5dfdd8bfbe7c681d6a6a6964
2025-10-27 11:10:57 +03:00
955a5d51f8
multicall: cleanup; match wl-copy/wl-paste interfaces more closely
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8cc05c0141cccff8378ef4fd83ccf77d6a6a6964
2025-10-25 08:38:53 +03:00
7a4f6378e9
nix: build with the mold linker on x86_64-linux
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5d1e28f9b74fe1a4881a7105722ef3376a6a6964
2025-10-25 08:37:21 +03:00
d3911dd81a
nix: add NixOS module
Closes #3

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964ecdb3bf1e5f7e2b902713eb8d2755ad1
2025-10-23 15:10:46 +03:00
b50702480f
meta: rename 'vendor' to contrib; don't vendor service in Nix derivation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696479e976a7f4db18e6501e347a4940ce28
2025-10-23 15:10:45 +03:00
a9da424e70
chore: release v0.3.2
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964fc36c56f505e7a679727e1f4f7d7095c
2025-10-14 08:48:50 +03:00
0a8fda66a0
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964fc9d82b2ca85798ea4dda196e0a25e33
2025-10-14 08:48:49 +03:00
a94ef7f5b4
nix: install multicall binaries in postInstall
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646b6afeb1d9dda8d16e00f6f39b8046ff
2025-10-14 08:48:45 +03:00
a59e207e76
ci: trigger Nix cache action more often
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696463b203052b421a924fb85885fc34752b
2025-10-09 16:42:40 +03:00
raf
7f6949b001
Merge pull request #39 from NotAShelf/notashelf/push-vtwowuvxsnos
stash: add multicall support for stash-copy and stash-paste
2025-10-09 15:04:27 +03:00
c2427c138a
docs: mention multicall exports
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964c5f2d863774a214ab54bc17caa7c9bbb
2025-10-09 14:58:34 +03:00
78fa23a764
multicall: remove program name prefixes from log and error messages
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964f65a0f1e473a50abfa985365ad8f1fa1
2025-10-09 14:44:45 +03:00
6496d3963d
stash: add multicall support for stash-copy and stash-paste
We can finally tell the users that they can uninstall `wl-copy` and
`wl-paste` on their systems. Stash now somewhat supports being invoked
under the names `stash-copy` and `stash-paste` to fully reimplement the
functionality of `wl-copy` and `wl-paste` respectively.

A build wrapper has been added generate symlinks for `stash-copy`, `stash-paste`,
`wl-copy`, and `wl-paste`. `wl-copy` and `wl-paste` links are provided
only for backwards compatibility, but they will not go away anytime
soon.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964463b35427cb720fbab68b252944cc90c
2025-10-09 14:40:54 +03:00
raf
74f9374a4e
Merge pull request #38 from NotAShelf/notashelf/push-xmznywpywuqo
list: add clipboard actions for delete and  copy; notify
2025-10-09 14:22:06 +03:00
f8440926b1
list: log clipboard copy errors and update notification message
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69649bdd37a44f254d520e33a54634958ada
2025-10-09 11:45:37 +03:00
d8b1ac1f37
list: properly error notification if clipboard copy fails
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696459d7fbc344545daeead6164cad5cde6f
2025-10-09 11:45:36 +03:00
4c0782f80e
list: add clipboard actions for delete and copy; notify
This adds an optional dependency on notify-rust, which we use to display
notifications when an entry is deleted or copied. If the user thinks a
TUI using desktop notifications is *not* desirable, it can be disabled
with the `notifications` feature flag.

We now support copying entries to the clipboard with `Enter` and
deleting entries with `Shift+D`. Both of those will show notifications.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69642d0c13a1359b3b51125cc4b691cd5679
2025-10-09 11:08:50 +03:00
514572b804
nix: add a 'stash' package alias
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69644d284206d2502da6f21997293fecd784
2025-10-09 09:19:05 +03:00
raf
dd4a9b5894
Merge pull request #36 from NotAShelf/dependabot/cargo/serde-1.0.228
build(deps): bump serde from 1.0.226 to 1.0.228
2025-09-30 12:45:40 +03:00
dependabot[bot]
3d0810c824
build(deps): bump serde from 1.0.226 to 1.0.228
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.226 to 1.0.228.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.226...v1.0.228)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.228
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-29 18:59:20 +00:00
raf
91be1ad241
Merge pull request #34 from NotAShelf/dependabot/cargo/thiserror-2.0.17
build(deps): bump thiserror from 2.0.16 to 2.0.17
2025-09-29 21:52:49 +03:00
raf
0a803a6a40
Merge pull request #35 from NotAShelf/dependabot/cargo/regex-1.11.3
build(deps): bump regex from 1.11.2 to 1.11.3
2025-09-29 21:52:40 +03:00
dependabot[bot]
23d585a34c
build(deps): bump regex from 1.11.2 to 1.11.3
Bumps [regex](https://github.com/rust-lang/regex) from 1.11.2 to 1.11.3.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.11.2...1.11.3)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.11.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-29 18:41:27 +00:00
dependabot[bot]
b847460b3c
build(deps): bump thiserror from 2.0.16 to 2.0.17
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.16 to 2.0.17.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.16...2.0.17)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-29 18:39:49 +00:00
raf
868a4c7fca
Merge pull request #32 from NotAShelf/dependabot/cargo/clap-4.5.48
build(deps): bump clap from 4.5.47 to 4.5.48
2025-09-23 11:01:02 +03:00
raf
4c36496a47
Merge pull request #33 from NotAShelf/dependabot/cargo/serde-1.0.226
build(deps): bump serde from 1.0.224 to 1.0.226
2025-09-23 11:00:38 +03:00
dependabot[bot]
8c95ec6051
build(deps): bump serde from 1.0.224 to 1.0.226
Bumps [serde](https://github.com/serde-rs/serde) from 1.0.224 to 1.0.226.
- [Release notes](https://github.com/serde-rs/serde/releases)
- [Commits](https://github.com/serde-rs/serde/compare/v1.0.224...v1.0.226)

---
updated-dependencies:
- dependency-name: serde
  dependency-version: 1.0.226
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 14:44:58 +00:00
dependabot[bot]
556e7d2ba1
build(deps): bump clap from 4.5.47 to 4.5.48
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.47 to 4.5.48.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.47...clap_complete-v4.5.48)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.48
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-22 14:44:46 +00:00
a70c7d7014
ci: add the missing nix installation step for release workflow
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964d99e447844d397624ae79d26e6e81817
2025-09-19 14:13:17 +03:00
301a678f56
chore: release v0.3.1
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646a11adf5dcf39f6caa79390d13662bfe
2025-09-19 14:08:16 +03:00
a41d72fb6b
stash: refactor error handling and entry deduplication
This includes breaking changes to the database entries, where we have
started deduplicating based on hashes instead of full entries. Entry
collisions are possible, but highly unlikely.

Additionally we use `Box<str>` for error variants to reduce allocations.
This is *yet* to give me a non-marginal performance benefit but doesn't
hurt to be more correct.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964d0a33392da61372214ca3088551564ac
2025-09-19 14:08:15 +03:00
d05ad311a9
wayland: remove closed toplevels on event
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69644f5e067b3533a7f62c42d4a6d01be00b
2025-09-19 14:08:14 +03:00
acb6657e73
chore: release crate to "stash-clipboard"
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69640a8b0ccc2d8bb11181c9fdeb1397c329
2025-09-19 14:08:13 +03:00
f40e11195c
chore: add missing description field to crate manifest
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696432a624912508f46fae70a66783e0f7fe
2025-09-19 11:32:46 +03:00
e92cdc444d
chore: release v0.3.0
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69648f92f658da9ff14bd5e7d864b6cc6584
2025-09-19 11:28:22 +03:00
7857dc2d2d
ci: tag releases automatically
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964dc061848bfbda520a4e311c3f9558557
2025-09-19 11:25:58 +03:00
2bbd8d11c2
docs: describe new app exclusion feature
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696479a0c3a1e302b3abb1cfab4d95ae5b11
2025-09-19 11:22:59 +03:00
36c183742d
stash: blocking persistent entries by window class
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964061bd97b4ffc4e84d835072331a966c6
2025-09-19 11:22:58 +03:00
e5204c4a3a
meta: gitignore everything by default
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964f4f01faeb5551718574c19cc2fa12c57
2025-09-19 11:22:57 +03:00
d1e348df9e
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964707aec82d862a37d28113667d3cc6162
2025-09-15 19:56:49 +03:00
raf
b0fbdcf3ea
Merge pull request #28 from NotAShelf/dependabot/cargo/inquire-0.8.0
build(deps): bump inquire from 0.7.5 to 0.8.0
2025-09-15 18:13:07 +03:00
dependabot[bot]
e82f2911d0
build(deps): bump inquire from 0.7.5 to 0.8.0
Bumps [inquire](https://github.com/mikaelmello/inquire) from 0.7.5 to 0.8.0.
- [Release notes](https://github.com/mikaelmello/inquire/releases)
- [Changelog](https://github.com/mikaelmello/inquire/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mikaelmello/inquire/compare/v0.7.5...v0.8.0)

---
updated-dependencies:
- dependency-name: inquire
  dependency-version: 0.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-15 15:08:56 +00:00
raf
ad808c73c7
Merge pull request #26 from NotAShelf/dependabot/cargo/log-0.4.28
build(deps): bump log from 0.4.27 to 0.4.28
2025-09-09 11:32:33 +03:00
dependabot[bot]
3e9aa6b2a3
build(deps): bump log from 0.4.27 to 0.4.28
Bumps [log](https://github.com/rust-lang/log) from 0.4.27 to 0.4.28.
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.27...0.4.28)

---
updated-dependencies:
- dependency-name: log
  dependency-version: 0.4.28
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 14:38:54 +00:00
raf
3176c96514
Merge pull request #25 from NotAShelf/dependabot/cargo/clap-4.5.46
build(deps): bump clap from 4.5.45 to 4.5.46
2025-09-02 10:39:32 +03:00
dependabot[bot]
d65d85676f
build(deps): bump clap from 4.5.45 to 4.5.46
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.45 to 4.5.46.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.45...clap_complete-v4.5.46)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-02 02:26:23 +00:00
raf
c9b19f1e64
Merge pull request #24 from NotAShelf/dependabot/cargo/regex-1.11.2
build(deps): bump regex from 1.11.1 to 1.11.2
2025-08-26 08:24:49 +03:00
dependabot[bot]
57dcea219d
build(deps): bump regex from 1.11.1 to 1.11.2
Bumps [regex](https://github.com/rust-lang/regex) from 1.11.1 to 1.11.2.
- [Release notes](https://github.com/rust-lang/regex/releases)
- [Changelog](https://github.com/rust-lang/regex/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/regex/compare/1.11.1...1.11.2)

---
updated-dependencies:
- dependency-name: regex
  dependency-version: 1.11.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-26 05:03:16 +00:00
c9a73b462d
commands: more consistent error propagation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69647a0eb8de028e4251465fbb94f0a14cef
2025-08-20 18:38:05 +03:00
ae98cc0b86
stash: replace atty with is_terminal from std::io
It's deprecated, oops.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646dc5a20ff1fde23ea9a846a7a3fdd16a
2025-08-20 18:38:04 +03:00
raf
7aa28a871e
Merge pull request #23 from Rexcrazy804/reset-autoincrement
reset auto increment on stash wipe
2025-08-20 18:14:59 +03:00
Rexiel Scarlet
ef0d05cbad
reset auto increment on stash wipe 2025-08-20 18:53:46 +04:00
raf
03550b884d
Merge pull request #22 from NotAShelf/dependabot/github_actions/cachix/cachix-action-16
build(deps): bump cachix/cachix-action from 14 to 16
2025-08-20 14:06:31 +03:00
raf
d1a4fe7baa
Merge pull request #21 from NotAShelf/dependabot/cargo/thiserror-2.0.16
build(deps): bump thiserror from 2.0.14 to 2.0.16
2025-08-20 14:06:16 +03:00
dependabot[bot]
f50e59fb20
build(deps): bump cachix/cachix-action from 14 to 16
Bumps [cachix/cachix-action](https://github.com/cachix/cachix-action) from 14 to 16.
- [Release notes](https://github.com/cachix/cachix-action/releases)
- [Commits](https://github.com/cachix/cachix-action/compare/v14...v16)

---
updated-dependencies:
- dependency-name: cachix/cachix-action
  dependency-version: '16'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 11:04:49 +00:00
dependabot[bot]
9c52602f04
build(deps): bump thiserror from 2.0.14 to 2.0.16
Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.14 to 2.0.16.
- [Release notes](https://github.com/dtolnay/thiserror/releases)
- [Commits](https://github.com/dtolnay/thiserror/compare/2.0.14...2.0.16)

---
updated-dependencies:
- dependency-name: thiserror
  dependency-version: 2.0.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 11:04:30 +00:00
raf
56ea445190
Merge pull request #17 from NotAShelf/dependabot/cargo/serde_json-1.0.143
build(deps): bump serde_json from 1.0.142 to 1.0.143
2025-08-20 12:30:04 +03:00
raf
f019b9a8d3
Merge pull request #18 from NotAShelf/dependabot/cargo/clap-verbosity-flag-3.0.4
build(deps): bump clap-verbosity-flag from 3.0.3 to 3.0.4
2025-08-20 12:29:55 +03:00
raf
a23c8aa495
Merge pull request #19 from NotAShelf/dependabot/github_actions/cachix/install-nix-action-31
build(deps): bump cachix/install-nix-action from 25 to 31
2025-08-20 12:29:43 +03:00
raf
b7b1ca074c
Merge pull request #20 from NotAShelf/dependabot/github_actions/actions/checkout-5
build(deps): bump actions/checkout from 3 to 5
2025-08-20 12:29:29 +03:00
dependabot[bot]
6471080f91
build(deps): bump actions/checkout from 3 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 09:26:47 +00:00
dependabot[bot]
3291605463
build(deps): bump cachix/install-nix-action from 25 to 31
Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from 25 to 31.
- [Release notes](https://github.com/cachix/install-nix-action/releases)
- [Changelog](https://github.com/cachix/install-nix-action/blob/master/RELEASE.md)
- [Commits](https://github.com/cachix/install-nix-action/compare/v25...v31)

---
updated-dependencies:
- dependency-name: cachix/install-nix-action
  dependency-version: '31'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 09:24:51 +00:00
dependabot[bot]
3f09f4d043
build(deps): bump clap-verbosity-flag from 3.0.3 to 3.0.4
Bumps [clap-verbosity-flag](https://github.com/clap-rs/clap-verbosity-flag) from 3.0.3 to 3.0.4.
- [Changelog](https://github.com/clap-rs/clap-verbosity-flag/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap-verbosity-flag/compare/v3.0.3...v3.0.4)

---
updated-dependencies:
- dependency-name: clap-verbosity-flag
  dependency-version: 3.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 09:24:31 +00:00
dependabot[bot]
d8b75f78f2
build(deps): bump serde_json from 1.0.142 to 1.0.143
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.142 to 1.0.143.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.142...v1.0.143)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.143
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-20 08:58:42 +00:00
ea721a6eb2
docs: clean up readme; add badges
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964d59a918b24564e02f065d9efc758df39
2025-08-20 11:06:55 +03:00
df8e58ed75
release under MPL v2.0
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964cfec4faf350c014a4df50a29de51bad8
2025-08-20 11:06:54 +03:00
b1a220400d
stash: add help text for cliphist compat flags
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964aa5c4ab66d13fe6e0d38400cdde5de1a
2025-08-20 11:06:53 +03:00
1ed518a3b6
stash: move import logic into stash::commmands
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964f8fb8c9b14049ba3a343bb453ca59004
2025-08-20 11:06:52 +03:00
8423dffdfe
nix: cleanup; add homepage
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69644205647b67cb5b8f15ae97db513d51e7
2025-08-20 11:06:51 +03:00
da8f01b286
chore: remove unused rmp-serde dep
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964931e33d4826e62c9264eebed6b2e11e6
2025-08-20 11:06:50 +03:00
4aa6ef94d8
meta: add workflow perms; fix broken workflows
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964ea7f970a8a9040ca994bfe6aabecb408
2025-08-20 10:21:43 +03:00
383731e47c
meta: set up dependabot for Cargo dependency updates
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646862a82c2661acb73a4fd06c4092365e
2025-08-20 10:21:42 +03:00
raf
bafe272a83
Merge pull request #16 from NotAShelf/notashelf/push-qwkxlsnpqyyo
stash: improvements to import command
2025-08-20 09:59:07 +03:00
f39937d3ca
stash: import all entries by default; log if db import fails
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964804698d2e83a37ec2688e9c126cf412b
2025-08-20 09:57:45 +03:00
6a5cd9b95d
treewide: format with rustfmt
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69642c2865f41a4b141ddf39a198a3fc2e09
2025-08-20 09:57:44 +03:00
404990f928
nix: update devshell
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696451203d6ac74ae44dddec1bdce19e78d9
2025-08-20 09:57:43 +03:00
2db9a2904d
meta: set formatter
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964ca3adbea0b57e460543416888190d8d9
2025-08-20 09:57:42 +03:00
47fd5e4964
db: support other image formats supported by imagesize
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646b72b8bc223fe9729be3dbefbae2b353
2025-08-15 08:04:36 +03:00
7c26947437
commands/list: resolve clippy warnings
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964e1bb8c22b5026ce65889e3aec1b90a71
2025-08-15 08:04:35 +03:00
83d45c6414
list: if we're in a TTY, output data in a TUI
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696493f2e7ca911b6c1e2692b67f357a4b6b
2025-08-15 08:04:24 +03:00
raf
7ccaf13bda
Merge pull request #14 from Rexcrazy804/kill-ifd
nix: removal of ifd
2025-08-14 22:22:43 +03:00
Rexiel Scarlet
864b5cb93e
nix: removal of ifd 2025-08-14 23:06:44 +04:00
raf
b4762f3050
Merge pull request #13 from Rexcrazy804/patch-1
docs: correct invalid stash import snippet in README
2025-08-14 19:16:44 +03:00
Rexiel Scarlet
d939d8be01
README: stash --import -> stash import
corrected invalid stash command
2025-08-14 16:05:29 +00:00
raf
dd1c3b22da
Merge pull request #11 from Rexcrazy804/nix-incremental
nix: incremental builds with crane
2025-08-14 18:29:13 +03:00
Rexiel Scarlet
464daf3d71
workflows: addition of nix cache workflow 2025-08-14 19:24:33 +04:00
Rexiel Scarlet
d2996ba521
nix: incremental builds with crane 2025-08-14 19:24:07 +04:00
raf
40f4c1196d
Merge pull request #12 from NotAShelf/notashelf/push-lrzrznnxzlpw
stash: allow confirming destructive operations; add filter
2025-08-14 18:10:38 +03:00
0547376a9e
chore: apply clippy fixes; suppress "too many lines" lint
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696476135218b9979677c66c4be4d96aced8
2025-08-14 17:31:26 +03:00
261b154527
stash: use LoadCrediental in the vendored Systemd service
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964e8080cc20ea3c78073141a1978f0b1f1
2025-08-14 17:24:22 +03:00
bbe3a0fd8d
docs: update README with filter options
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a696403633a49cbefdfe38bc8d6064fdd5a25
2025-08-14 17:24:21 +03:00
f3089148e0
db: allow explicitly skipping sensitive entries
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964ed1deaac0215ae9c6f4c70cfdc50164d
2025-08-14 17:24:20 +03:00
0c0547b6e8
chore: bump dependencies
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69647235c4fe03eb0c5e6ea1290110e29ccf
2025-08-14 17:24:19 +03:00
86001652cd
stash: allow confirming destructive operations with --ask
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69644c23734a8b088e20473d381390d532b4
2025-08-14 17:24:01 +03:00
f6bf5586ad
import: nesting is ew yuck
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964becfe6f31b0411110b75d4cdfe2b9c63
2025-08-14 10:53:40 +03:00
673bcb01be
docs: add installation instructions
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69646bf49248120e693a4793c22441dd74cb
2025-08-14 10:53:39 +03:00
47b85472fa
stash: use stash watch for the vendored systemd service
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69649280b8e52b3ee557d8f47091b54515f9
2025-08-13 18:49:28 +03:00
989ab7e4c3
chore: bump crate version
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a69642b437257ad6c584694fcc084b0572a57
2025-08-13 18:47:27 +03:00
38 changed files with 10422 additions and 1500 deletions

23
.github/dependabot.yaml vendored Normal file
View file

@ -0,0 +1,23 @@
version: 2
updates:
# Update used workflows
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: daily
# Update Cargo deps
- package-ecosystem: cargo
directory: "/"
cooldown:
default-days: 7
schedule:
interval: "weekly"
# Update Nixpkgs & Crane
- package-ecosystem: nix
directory: "/"
cooldown:
default-days: 7
schedule:
interval: daily

29
.github/workflows/nix-cache.yaml vendored Normal file
View file

@ -0,0 +1,29 @@
name: Build and Cache with Nix
on:
workflow_dispatch:
push:
branches: [ "main" ]
paths: [ 'src/**.rs', 'build.rs', 'Cargo.toml', 'Cargo.lock', 'nix/package.nix', 'flake.nix', 'flake.lock' ]
permissions:
contents: read
jobs:
populate-cache:
runs-on: ubuntu-latest
steps:
- name: "Checkout"
uses: actions/checkout@v6
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- uses: cachix/cachix-action@v17
with:
name: nyx
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: "Build with Nix"
run: nix build

View file

@ -9,7 +9,30 @@ permissions:
contents: write
jobs:
tag-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- uses: cachix/install-nix-action@v31
with:
nix_path: nixpkgs=channel:nixos-unstable
- name: Read version
run: |
echo -n "stash_version=v" >> "$GITHUB_ENV"
nix run nixpkgs#fq -- -r '.package.version' Cargo.toml >> "$GITHUB_ENV"
cat "$GITHUB_ENV"
- name: Tag
run: |
set -x
git tag $ndg_version
git push --tags || :
create-release:
needs: tag-release
runs-on: ubuntu-latest
outputs:
upload_url: ${{ steps.create_release.outputs.upload_url }}
@ -17,7 +40,7 @@ jobs:
steps:
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: false
prerelease: false
@ -39,7 +62,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
@ -75,7 +98,7 @@ jobs:
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
files: ${{ matrix.name }}
@ -83,7 +106,7 @@ jobs:
needs: [create-release, build-release]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v6
- name: Download Assets
uses: robinraju/release-downloader@v1
@ -97,7 +120,7 @@ jobs:
sha256sum stash-* > SHA256SUMS
- name: Upload Checksums
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
files: SHA256SUMS

View file

@ -1,10 +1,13 @@
name: Build with Cargo
permissions:
contents: read
on:
workflow_dispatch:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
paths: [ "src/**.rs", "Cargo.toml", "Cargo.lock"]
env:
CARGO_TERM_COLOR: always
@ -13,6 +16,8 @@ jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Checkout
uses: actions/checkout@v6
- name: Build
run: cargo build --verbose

37
.gitignore vendored
View file

@ -1,3 +1,34 @@
target/
.direnv/
result/
# Ignore everything by default
/*
!/
!/nix
!/src
!/contrib
# Rust/Cargo
!/Cargo.lock
!/Cargo.toml
!/build.rs
# Configuration files
!/.config/
!/.rustfmt.toml
!/.clippy.toml
!/.taplo.toml
!/.gitattributes
!/.gitignore
!/.github
!/.editorconfig
# Nix
!/flake/**/*.nix
!/flake.nix
!/flake.lock
!/shell.nix
!/default.nix
!/.envrc
# Misc
!/README.md
!/LICENSE

26
.rustfmt.toml Normal file
View file

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

13
.taplo.toml Normal file
View file

@ -0,0 +1,13 @@
[formatting]
align_entries = true
column_width = 110
compact_arrays = false
reorder_inline_tables = false
reorder_keys = true
[[rule]]
include = [ "**/Cargo.toml" ]
keys = [ "package" ]
[rule.formatting]
reorder_keys = false

3104
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +1,59 @@
[package]
name = "stash"
version = "0.2.3"
edition = "2024"
authors = ["NotAShelf <raf@notashelf.dev>"]
license = "MPL-2.0"
readme = true
repository = "https://github.com/notashelf/stash"
rust-version = "1.85"
name = "stash-clipboard"
description = "Wayland clipboard manager with fast persistent history and multi-media support"
version = "0.3.6"
edition = "2024"
authors = [ "NotAShelf <raf@notashelf.dev>" ]
license = "MPL-2.0"
readme = true
repository = "https://github.com/notashelf/stash"
rust-version = "1.91.0"
[[bin]]
name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs"
[dependencies]
clap = { version = "4.5.44", features = ["derive"] }
dirs = "6.0.0"
serde = { version = "1.0.219", features = ["derive"] }
rmp-serde = "1.3.0"
imagesize = "0.14"
log = "0.4.27"
env_logger = "0.11.8"
clap-verbosity-flag = "3.0.3"
thiserror = "2.0.14"
wl-clipboard-rs = "0.9.2"
rusqlite = { version = "0.37.0", features = ["bundled"] }
smol = "2.0.2"
serde_json = "1.0.142"
base64 = "0.22.1"
arc-swap = { version = "1.9.1", optional = true }
base64 = "0.22.1"
blocking = "1.6.2"
clap = { version = "4.6.0", features = [ "derive", "env" ] }
clap-verbosity-flag = "3.0.4"
color-eyre = "0.6.5"
crossterm = "0.29.0"
ctrlc = "3.5.2"
dirs = "6.0.0"
env_logger = "0.11.10"
humantime = "2.3.0"
imagesize = "0.14.0"
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
libc = "0.2.184"
log = "0.4.29"
mime-sniffer = "0.1.3"
notify-rust = { version = "4.14.0", optional = true }
ratatui = "0.30.0"
regex = "1.12.3"
rusqlite = { version = "0.39.0", features = [ "bundled" ] }
serde = { version = "1.0.228", features = [ "derive" ] }
serde_json = "1.0.149"
smol = "2.0.2"
thiserror = "2.0.18"
unicode-segmentation = "1.13.2"
unicode-width = "0.2.2"
wayland-client = { version = "0.31.14", features = [ "log" ], optional = true }
wayland-protocols-wlr = { version = "0.3.12", default-features = false, optional = true }
wl-clipboard-rs = "0.9.3"
[dev-dependencies]
futures = "0.3.32"
tempfile = "3.27.0"
[features]
default = [ "notifications", "use-toplevel" ]
notifications = [ "dep:notify-rust" ]
use-toplevel = [ "dep:arc-swap", "dep:wayland-client", "dep:wayland-protocols-wlr" ]
[profile.release]
lto = true
lto = true
opt-level = "z"
strip = true
strip = true

328
LICENSE Normal file
View file

@ -0,0 +1,328 @@
Mozilla Public License, version 2.0
1. Definitions
1.1. “Contributor”
means each individual or legal entity that creates, contributes to the
creation of, or owns Covered Software.
1.2. “Contributor Version”
means the combination of the Contributions of others (if any) used by a
Contributor and that particular Contributors Contribution.
1.3. “Contribution”
means Covered Software of a particular Contributor.
1.4. “Covered Software”
means Source Code Form to which the initial Contributor has attached the
notice in Exhibit A, the Executable Form of such Source Code Form,
and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. “Incompatible With Secondary Licenses”
means
a. that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
b. that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the terms
of a Secondary License.
1.6. “Executable Form”
means any form of the work other than Source Code Form.
1.7. “Larger Work”
means a work that combines Covered Software with other material,
in a separate file or files, that is not Covered Software.
1.8. “License”
means this document.
1.9. “Licensable”
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently,
any and all of the rights conveyed by this License.
1.10. “Modifications”
means any of the following:
a. any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered Software; or
b. any new file in Source Code Form that contains any Covered Software.
1.11. “Patent Claims” of a Contributor
means any patent claim(s), including without limitation, method, process,
and apparatus claims, in any patent Licensable by such Contributor that
would be infringed, but for the grant of the License, by the making,
using, selling, offering for sale, having made, import, or transfer of
either its Contributions or its Contributor Version.
1.12. “Secondary License”
means either the GNU General Public License, Version 2.0, the
GNU Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those licenses.
1.13. “Source Code Form”
means the form of the work preferred for making modifications.
1.14. “You” (or “Your”)
means an individual or a legal entity exercising rights under this License.
For legal entities, “You” includes any entity that controls,
is controlled by, or is under common control with You. For purposes of
this definition, “control” means (a) the power, direct or indirect,
to cause the direction or management of such entity, whether by contract
or otherwise, or (b) ownership of more than fifty percent (50%) of the
outstanding shares or beneficial ownership of such entity.
2. License Grants and Conditions
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
a. under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications,
or as part of a Larger Work; and
b. under Patent Claims of such Contributor to make, use, sell,
offer for sale, have made, import, and otherwise transfer either
its Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor
first distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted
under this License. No additional rights or licenses will be implied
from the distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted
by a Contributor:
a. for any code that a Contributor has removed from
Covered Software; or
b. for infringements caused by: (i) Your and any other third partys
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its
Contributor Version); or
c. under Patent Claims infringed by Covered Software in the
absence of its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License
(if permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing,
or other equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the
licenses granted in Section 2.1.
3. Responsibilities
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including
any Modifications that You create or to which You contribute, must be
under the terms of this License. You must inform recipients that the
Source Code Form of the Covered Software is governed by the terms
of this License, and how they can obtain a copy of this License.
You may not attempt to alter or restrict the recipients rights
in the Source Code Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
a. such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more than
the cost of distribution to the recipient; and
b. You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of
Covered Software with a work governed by one or more Secondary Licenses,
and the Covered Software is not Incompatible With Secondary Licenses,
this License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the
Covered Software under the terms of either this License or such
Secondary License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of
Covered Software. However, You may do so only on Your own behalf,
and not on behalf of any Contributor. You must make it absolutely clear
that any such warranty, support, indemnity, or liability obligation is
offered by You alone, and You hereby agree to indemnify every Contributor
for any liability incurred by such Contributor as a result of warranty,
support, indemnity or liability terms You offer. You may include
additional disclaimers of warranty and limitations of liability
specific to any jurisdiction.
4. Inability to Comply Due to Statute or Regulation
If it is impossible for You to comply with any of the terms of this License
with respect to some or all of the Covered Software due to statute,
judicial order, or regulation then You must: (a) comply with the terms of
this License to the maximum extent possible; and (b) describe the limitations
and the code they affect. Such description must be placed in a text file
included with all distributions of the Covered Software under this License.
Except to the extent prohibited by statute or regulation, such description
must be sufficiently detailed for a recipient of ordinary skill
to be able to understand it.
5. Termination
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means,
this is the first time You have received notice of non-compliance with
this License from such Contributor, and You become compliant prior to
30 days after Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted
to You by any and all Contributors for the Covered Software under
Section 2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
6. Disclaimer of Warranty
Covered Software is provided under this License on an “as is” basis, without
warranty of any kind, either expressed, implied, or statutory, including,
without limitation, warranties that the Covered Software is free of defects,
merchantable, fit for a particular purpose or non-infringing. The entire risk
as to the quality and performance of the Covered Software is with You.
Should any Covered Software prove defective in any respect, You
(not any Contributor) assume the cost of any necessary servicing, repair,
or correction. This disclaimer of warranty constitutes an essential part of
this License. No use of any Covered Software is authorized under this
License except under this disclaimer.
7. Limitation of Liability
Under no circumstances and under no legal theory, whether tort
(including negligence), contract, or otherwise, shall any Contributor, or
anyone who distributes Covered Software as permitted above, be liable to
You for any direct, indirect, special, incidental, or consequential damages
of any character including, without limitation, damages for lost profits,
loss of goodwill, work stoppage, computer failure or malfunction, or any and
all other commercial damages or losses, even if such party shall have been
informed of the possibility of such damages. This limitation of liability
shall not apply to liability for death or personal injury resulting from
such partys negligence to the extent applicable law prohibits such
limitation. Some jurisdictions do not allow the exclusion or limitation of
incidental or consequential damages, so this exclusion and limitation may
not apply to You.
8. Litigation
Any litigation relating to this License may be brought only in the courts of
a jurisdiction where the defendant maintains its principal place of business
and such litigation shall be governed by laws of that jurisdiction, without
reference to its conflict-of-law provisions. Nothing in this Section shall
prevent a partys ability to bring cross-claims or counter-claims.
9. Miscellaneous
This License represents the complete agreement concerning the subject matter
hereof. If any provision of this License is held to be unenforceable,
such provision shall be reformed only to the extent necessary to make it
enforceable. Any law or regulation which provides that the language of a
contract shall be construed against the drafter shall not be used to construe
this License against a Contributor.
10. Versions of the License
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in
Section 10.3, no one other than the license steward has the right to
modify or publish new versions of this License. Each version will be
given a distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published
by the license steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a modified
version of this License if you rename the license and remove any
references to the name of the license steward (except to note that such
modified license differs from this License).
10.4. Distributing Source Code Form that is
Incompatible With Secondary Licenses
If You choose to distribute Source Code Form that is
Incompatible With Secondary Licenses under the terms of this version of
the License, the notice described in Exhibit B of this
License must be attached.
Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the
Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed
with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular file,
then You may include the notice in a location (such as a LICENSE file in a
relevant directory) where a recipient would be likely to
look for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - “Incompatible With Secondary Licenses” Notice
This Source Code Form is “Incompatible With Secondary Licenses”,
as defined by the Mozilla Public License, v. 2.0.

520
README.md
View file

@ -1,25 +1,197 @@
# Stash
<!-- markdownlint-disable MD033 -->
Wayland clipboard "manager" with fast persistent history and multi-media
support. Stores and previews clipboard entries (text, images) on the command
line.
<h1 id="header" align="center">
<pre>Stash</pre>
</h1>
<div align="center">
<a alt="CI Status" href="https://github.com/NotAShelf/stash/actions">
<img
src="https://github.com/NotAShelf/stash/actions/workflows/rust.yml/badge.svg"
alt="Build Status"
/>
</a>
<a alt="Dependencies" href="https://deps.rs/repo/github/notashelf/stash">
<img
src="https://deps.rs/repo/github/notashelf/stash/status.svg"
alt="Dependency Status"
/>
</a>
</div>
<div align="center">
Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and
robust multi-media support. Stores and previews clipboard entries (text, images)
on the clipboard with a neat TUI and advanced scripting capabilities.
</div>
<div align="center">
<br/>
<a href="#features">Features</a><br/>
<a href="#installation">Installation</a> | <a href="#usage">Usage</a> | <a href="#usage">Motivation</a></br>
<a href="#tips--tricks">Tips and Tricks</a>
<br/>
</div>
## Features
- Stores clipboard entries with automatic MIME detection
Stash is a feature-rich, yet simple and lightweight clipboard management utility
with many features such as but not necessarily limited to:
- Automatic MIME detection for stored entries
- Fast persistent storage using SQLite
- List, search, decode, delete, and wipe clipboard history
- List, search, decode, delete, and wipe clipboard history with ease
- Backwards compatible with Cliphist TSV format
- Import clipboard history from TSV (e.g., from `cliphist list`)
- Image preview (shows dimensions and format)
- Deduplication and entry limit control
- Text previews with customizable width
- De-duplication, whitespace prevention and entry limit control
- Automatic clipboard monitoring with
[`stash watch`](#watch-clipboard-for-changes-and-store-automatically)
- Configurable auto-expiry of old entries in watch mode as a safety buffer
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
- Sensitive clipboard filtering via regex (see below)
- Sensitive clipboard filtering by application (see below)
on top of the existing features of Cliphist, which are as follows:
- Write clipboard changes to a history file.
- Recall history with dmenu, rofi, wofi (or whatever other picker you like).
- Both text and images are supported.
- Clipboard is preserved byte-for-byte.
- Leading/trailing whitespace, no whitespace, or newlines are preserved.
- Wont break fancy editor selections like Vim wordwise, linewise, or block
mode.
Most of Stash's usage is documented in the [usage section](#usage) for more
details. Refer to the [Tips & Tricks section](#tips--tricks) for more "advanced"
features, or conveniences provided by Stash.
## Installation
### With Nix
Nix is the recommended way of downloading (and developing!) Stash. You can
install it using Nix flakes using `nix profile add` if on non-nixos or add Stash
as a flake input if you are on NixOS.
```nix
{
# Add Stash to your inputs like so
inputs.stash.url = "github:NotAShelf/stash";
outputs = { /* ... */ };
}
```
Then you can get the package from your flake input, and add it to your packages
to make `stash` available in your system.
```nix
{inputs, pkgs, ...}: let
stashPkg = inputs.stash.packages.${pkgs.stdenv.hostPlatform}.stash;
in {
environment.systemPackages = [stashPkg];
# Additionally feel free to add the Stash package in `systemd.packages` to
# automatically run the Stash watch daemon, which will watch your primary
# clipboard for changes and persist them.
systemd.packages = [stashPkg];
}
```
If you want to give Stash a try before you switch to it, you may also run it one
time with `nix run`.
```sh
# Run directly from the git repository; will be garbage collected
$ nix run github:NotAShelf/stash -- watch # start the watch daemon
```
### Without Nix
[GitHub Releases]: https://github.com/notashelf/stash/releases
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
[GitHub Releases]. To install Stash on your system without Nix, either:
- Download a tagged release from [GitHub Releases] for your platform and place
the binary in your `$PATH`. Instructions may differ based on your
distribution, but generally you want to download the built binary from
releases and put it somewhere like `/usr/bin` or `~/.local/bin` depending on
your distribution.
- Build and install from source with Cargo:
```bash
cargo install stash --locked
```
Additionally, you may get Stash from source via `cargo install` using
`cargo install --git https://github.com/notashelf/stash --locked` or you may
check out to the repository, and use Cargo to build it. You'll need Rust 1.91.0
or above. Most distributions should package this version already. You may, of
course, prefer to package the built releases if you'd like.
## 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.
> [!IMPORTANT]
> It is not a priority to provide 1:1 backwards compatibility with Cliphist.
> While the interface is generally similar, Stash chooses to build upon
> Cliphist's design and extend existing design choices. See
> [Migrating from Cliphist](#migrating-from-cliphist) for more details. Refer to
> help text if confused.
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
db Database management operations
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 [env: STASH_DB_PATH=]
--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
@ -33,18 +205,39 @@ echo "some clipboard text" | stash store
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>`.
You may also view your clipboard _with the addition of expired entries_, i.e.,
entries that have reached their TTL and are marked as expired, using the
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
when using this flag, allowing you to inspect them before running cleanup.
### Decode an entry by ID
```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
```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)
```bash
@ -53,10 +246,33 @@ stash delete --type id < ids.txt
### Wipe all entries
> [!WARNING]
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
> instead.
```bash
stash wipe
```
### Database management
Stash provides a `db` subcommand for database maintenance operations:
```bash
stash db wipe [--expired] [--ask]
stash db vacuum
stash db stats
```
- `stash db wipe`: Remove all entries from the database. Use `--expired` to only
wipe expired entries instead of all entries. Requires `--ask` confirmation by
default.
- `stash db vacuum`: Optimize the database using SQLite's VACUUM command,
reclaiming space and improving performance.
- `stash db stats`: Display database statistics including total/active/expired
entry counts, storage size, and page information. This is provided purely for
convenience and the rule of the cool.
### Watch clipboard for changes and store automatically
```bash
@ -64,7 +280,64 @@ stash watch
```
This runs a daemon that monitors the clipboard and stores new entries
automatically.
automatically. This is designed as an alternative to shelling out to
`wl-paste --watch` inside a Systemd service or XDG autostart. You may find a
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
service unless adding their own.
#### Automatic Clipboard Clearing on Expiration
When `stash watch` is running and a clipboard entry expires, Stash will detect
if the current clipboard still contains that expired content and automatically
clear it. This prevents stale data from remaining in your clipboard after an
entry has expired from history.
> [!NOTE]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
#### MIME Type Preference for Watch
`stash watch` supports a `--mime-type` (short `-t`) option that lets you
prioritise which MIME type the daemon should request from the clipboard when
multiple representations are available.
- `any` (default): Request any available representation (current behaviour).
- `text`: Prefer text representations (e.g. `text/plain`, `text/html`).
- `image`: Prefer image representations (e.g. `image/png`, `image/jpeg`) so that
image copies from browsers or file managers are stored as images rather than
HTML fragments.
Example: prefer images when running the watch daemon
```bash
stash watch --mime-type image
```
This is useful when copying images from browsers or file managers where the
clipboard may offer both HTML and image representations; selecting `image` will
ask the compositor for image data first. Most users will be fine using the
default value (`any`) but in the case your browser (or other applications!)
regularly misrepresent data, you might wish to prioritize a different type.
#### Clipboard Persistence
By default, when you copy something and close the source application, Wayland
clears the clipboard. Stash can optionally keep the clipboard contents available
after the source closes using the `--persist` flag.
```bash
stash watch --persist
```
When enabled, Stash will fork a background process to serve the clipboard
contents, keeping them available even after the original application exits.
> [!NOTE]
> This feature is **opt-in** and disabled by default, as it may not be desirable
> for all users and can leave clipboard data in memory longer than expected. You
> must start the `stash watch` daemon with `--persist` for clipboard
> persistence.
### Options
@ -77,11 +350,90 @@ commands `--help` text for more details. The following are generally standard:
- `--preview-width <N>`: Text preview max width for `list`
- `--version`: Print the current version and exit
### Sensitive Clipboard Filtering
Stash can be configured to avoid storing clipboard entries that match a
sensitive pattern, using a regular expression. This is useful for preventing
accidental storage of secrets, passwords, or other sensitive data. You don't
want sensitive data ending up in your persistent clipboard, right?
The filter can be configured in one of three ways, as part of two separate
features.
#### Clipboard Filtering by Entry Regex
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
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
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
running as a Systemd service, you can provide a regex pattern using a crediental
file. For example, add to your `stash.service`:
```dosini
LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
```
The file `/etc/stash/clipboard_filter` should contain your regex pattern (no
quotes). This is done automatically in the
[vendored Systemd service](./contrib/stash.service). Remember to set the
appropriate file permissions if using this option.
The service will check the credential file first, then the environment variable.
If a clipboard entry matches the regex, it will be skipped and a warning will be
logged.
> [!TIP]
> **Example regex to block common password patterns**:
>
> `(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
Stash allows blocking an entry from the persistent history if it has been copied
from certain applications. This depends on the `use-toplevel` feature flag and
uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus
detection. While this feature flag is enabled (the default) you may use
`--excluded-apps` in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS`
environment variable to block entries from persisting in the database if they
are coming from your password manager for example. The entry is still copied to
the clipboard, but it will never be put inside the database.
This is a more robust alternative to using the regex method above, since you
likely do not want to catch your passwords with a regex. Simply pass your
password manager's **window class** to `--excluded-apps` and your passwords will
be only copied to the clipboard.
> [!TIP]
> **Example startup command for Stash daemon**:
>
> `stash --excluded-apps Bitwarden watch`
## Motivation
I've been a long-time user of Cliphist. You can probably tell by the number of
times it has been mentioned in the README, if not for the attributions section,
that Stash is _clearly_ inspired and adapted from it. It's actually a great
clipboard manager if your needs are simple, but mine aren't. I need an
**all-in-one** solution, that I can freely hack on, with simple solutions to
complex problems that I've had with managing my clipboard. I wanted it to be
scriptable _and_ interactive, I wanted it to be performant, I wanted it to be...
You get the point. Perhaps you also share similar needs, or just like Rust
software in general on your desktop. In either case, Stash hopes to serve as an
excellent clipboard manager for your needs, with _excellent_ performance.
## Tips & Tricks
### Migrating from Cliphist
Stash is designed to be a drop-in replacement for Cliphist, with only minor
Stash was designed to be a drop-in replacement for Cliphist, with only minor
improvements. If you are migrating from Cliphist, here are a few things you
should know.
@ -100,7 +452,7 @@ should know.
- Stash adds a `watch` command to automatically store clipboard changes. This is
an alternative to `wl-paste --watch cliphist list`. You can avoid shelling out
and depending on `wl-paste` as Stash implements it through `wl-clipboard-rs`
crate.
crate and provides its own `wl-copy` and `wl-paste` binaries.
### TSV Export and Import
@ -117,7 +469,7 @@ cliphist list --db ~/.cache/cliphist/db > cliphist.tsv
**Import TSV into Stash:**
```bash
stash --import < cliphist.tsv
stash import < cliphist.tsv
```
**Export TSV from Stash:**
@ -134,7 +486,135 @@ cliphist --import < stash.tsv
### More Tricks
- Use `stash list` to export your clipboard history in TSV format. This displays
your clipboard in the same format as `cliphist list`
- Use `stash import --type tsv` to import TSV clipboard history from Cliphist or
other tools.
Here are some other tips for Stash that are worth documenting. If you have
figured out something new, e.g. a neat shell trick, feel free to add it here!
1. You may use `stash list` to view your clipboard history in an interactive
TUI. This is obvious if you have ever ran the command, but here are some
things that you might not have known.
- `stash list` displays the TUI _only_ if the user is in an interactive TTY.
E.g. if it's a Bash script, `stash list` **will output TSV**.
- You can change the format with `--format` to e.g. JSON but you can also
force a TSV format inside an interactive session with `--format tsv`.
- `stash list` displays the mime type for newly recorded entries, but it will
not be able to display them for entries imported by Cliphist since Cliphist
never made a record of this data.
2. You can pipe `cliphist list --db ~/.cache/cliphist/db` to
`stash import --type tsv` to mimic importing from STDIN.
```bash
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.
### Entry Expiration
Stash supports time-to-live (TTL) for clipboard entries. When an entry's
expiration time is reached, it is marked as expired rather than immediately
deleted. This allows for inspection of expired entries and automatic clipboard
cleanup.
#### How Expiration Works
When `stash watch` is running with `--expire-after`, it monitors the clipboard
and processes expired entries periodically. Upon expiration:
1. The entry's `is_expired` flag is set to `1` in the database
2. If the current clipboard content matches the expired entry, Stash clears the
clipboard to prevent pasting stale data
3. Expired entries are excluded from normal list operations unless `--expired`
is specified
> [!NOTE]
> By default, entries do not expire. Use `stash watch --expire-after DURATION`
> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL).
#### Viewing Expired Entries
Use `stash list --expired` to include expired entries in the output. This is
useful for:
- Inspecting what has expired from your clipboard history
- Verifying that sensitive data has been properly expired
- Debugging expiration behavior
```bash
# View all entries including expired ones
stash list --expired
# View expired entries in JSON format
stash list --expired --format json
```
#### Cleaning Up Expired Entries
The watch daemon automatically cleans up expired entries when it processes them.
For manual cleanup, use:
```bash
# Remove all expired entries from the database
stash db wipe --expired
```
> [!NOTE]
> If you have a large number of expired entries, consider running
> `stash db vacuum` afterward to reclaim disk space.
#### Automatic Clipboard Clearing
When `stash watch` is running and an entry expires, Stash checks if the current
clipboard still contains that expired content. If it matches, Stash clears the
clipboard automatically. This prevents accidentally pasting outdated content.
> [!TIP]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
#### Database Maintenance
Stash uses SQLite for persistent storage. Over time, deleted entries and
fragmentation can affect performance. Use the `stash db` command to maintain
your database:
- **Check statistics**: `stash db stats` shows entry counts and storage usage.
Use this to monitor growth and decide when to clean up.
- **Remove expired entries**: `stash db wipe --expired` removes entries that
have reached their TTL. The daemon normally handles this, but this is useful
for manual cleanup.
- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to
reclaim space and defragment the database. This is safe to run periodically.
It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep
the database compact, especially after deleting many entries. You can, of
course, wipe the database entirely if it has grown too large.
## 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.
Secondly, but by no means less importantly, I would like to thank
[cliphist](https://github.com/sentriz/cliphist) for the excellent reference it
has provided to me as a "solid clipboard manager." The interface of Stash is
inspired by Cliphist, and it has served me very well for a very long time.
Additional and definitely heartfelt thanks to my testers, who have tested
earlier versions of Stash, helped with packaging and provided feedback. Thank
you :)
## License
This project is made available under Mozilla Public License (MPL) version 2.0.
See [LICENSE](LICENSE) for more details on the exact conditions. An online copy
is provided [here](https://www.mozilla.org/en-US/MPL/2.0/).

View file

@ -6,8 +6,9 @@ Requisite=graphical-session.target
[Service]
Type=simple
ExecStart=wl-paste --watch stash store
ExecStart=stash watch
Restart=on-failure
LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
[Install]
WantedBy=graphical-session.target

22
flake.lock generated
View file

@ -1,12 +1,27 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1775839657,
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1754725699,
"narHash": "sha256-iAcj9T/Y+3DBy2J0N+yF9XQQQ8IEb5swLFzs23CdP88=",
"lastModified": 1775710090,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "85dbfc7aaf52ecb755f87e577ddbe6dbbdbc1054",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
"type": "github"
},
"original": {
@ -18,6 +33,7 @@
},
"root": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs"
}
}

View file

@ -1,16 +1,28 @@
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
crane.url = "github:ipetkov/crane";
};
outputs = {
self,
nixpkgs,
crane,
}: let
systems = ["x86_64-linux" "aarch64-linux"];
forEachSystem = nixpkgs.lib.genAttrs systems;
pkgsForEach = nixpkgs.legacyPackages;
in {
packages = forEachSystem (system: {
default = pkgsForEach.${system}.callPackage ./nix/package.nix {};
nixosModules = {
stash = import ./nix/modules/nixos.nix self;
default = self.nixosModules.stash;
};
packages = forEachSystem (system: let
craneLib = crane.mkLib pkgsForEach.${system};
in {
stash = pkgsForEach.${system}.callPackage ./nix/package.nix {inherit craneLib;};
default = self.packages.${system}.stash;
});
devShells = forEachSystem (system: {

78
nix/modules/nixos.nix Normal file
View file

@ -0,0 +1,78 @@
self: {
config,
lib,
pkgs,
...
}: let
inherit (lib.modules) mkIf;
inherit (lib.options) mkOption mkEnableOption mkPackageOption literalMD;
inherit (lib.types) listOf str;
inherit (lib.strings) concatStringsSep;
inherit (lib.meta) getExe;
cfg = config.services.stash-clipboard;
in {
options.services.stash-clipboard = {
enable = mkEnableOption "stash, a Wayland clipboard manager";
package = mkPackageOption self.packages.${pkgs.system} ["stash"] {};
flags = mkOption {
type = listOf str;
default = [];
example = ["--max-items 10"];
description = "Flags to pass to stash watch.";
};
filterFile = mkOption {
type = str;
default = "";
example = "{file}`/etc/stash/clipboard_filter`";
description = literalMD ''
File containing a regular expression to catch sensitive patterns. The file
passed to this option must contain your regex pattern with no quotes.
::: {.tip}
Example regex to block common password patterns:
* `(password|secret|api[_-]?key|token)[=: ]+[^\s]+`
:::
'';
};
excludedApps = mkOption {
type = listOf str;
default = [];
example = ["Bitwarden"];
description = ''
Stash will avoid storing data if the active window class matches the
entries passed to this option. This is useful for avoiding persistent
passwords in the database, while still allowing one-time copies.
Entries from these apps are still copied to the clipboard, but it will
never be put inside the database.
'';
};
};
config = mkIf cfg.enable {
environment.systemPackages = [cfg.package];
systemd = {
packages = [cfg.package];
user.services.stash-clipboard = {
description = "Stash clipboard manager daemon";
wantedBy = ["graphical-session.target"];
after = ["graphical-session.target"];
serviceConfig = {
ExecStart = "${getExe cfg.package} ${concatStringsSep " " cfg.flags} watch";
LoadCredential = mkIf (cfg.filterFile != "") "clipboard_filter:${cfg.filterFile}";
};
environment = mkIf (cfg.excludedApps != []) {
STASH_EXCLUDED_APPS = concatStringsSep "," cfg.excludedApps;
};
};
};
};
}

View file

@ -1,11 +1,14 @@
{
lib,
rustPlatform,
}:
rustPlatform.buildRustPackage (finalAttrs: {
craneLib,
stdenv,
mold,
versionCheckHook,
useMold ? stdenv.isLinux,
createSymlinks ? true,
}: let
pname = "stash";
version = (builtins.fromTOML (builtins.readFile ../Cargo.toml)).package.version;
version = (lib.importTOML ../Cargo.toml).package.version;
src = let
fs = lib.fileset;
s = ../.;
@ -19,18 +22,50 @@ rustPlatform.buildRustPackage (finalAttrs: {
];
};
cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";
enableParallelBuilding = true;
postInstall = ''
mkdir -p $out
install -Dm755 ${../vendor/stash.service} $out/share/stash.service
'';
meta = {
description = "Wayland clipboard manager with fast persistent history and multi-media support";
maintainers = [lib.maintainers.NotAShelf];
license = lib.licenses.mpl20;
mainProgram = "stash";
cargoArtifacts = craneLib.buildDepsOnly {
name = "${pname}-deps";
strictDeps = true;
inherit src;
};
})
in
craneLib.buildPackage {
inherit pname src version cargoArtifacts;
strictDeps = true;
# Since Crane doesn't have a good way of enforcing that our symlinks
# generated by the build wrapper are correctly linked, we should link
# them *manually*. The postInstallCheck phase that follows will check
# to verify if all of those links are in place.
postInstall = lib.optionalString createSymlinks ''
mkdir -p $out
for bin in stash-copy stash-paste wl-copy wl-paste; do
ln -sf $out/bin/stash $out/bin/$bin
done
'';
nativeInstallCheckInputs = [versionCheckHook];
doInstallCheck = true;
# After the version check, let's see if all binaries are linked correctly.
# We could probably add a check phase to get the versions of each.
postInstallCheck = lib.optionalString createSymlinks ''
for bin in stash stash-copy stash-paste wl-copy wl-paste; do
[ -x "$out/bin/$bin" ] || { echo "$bin missing"; exit 1; }
done
'';
env = lib.optionalAttrs useMold {
CARGO_LINKER = "clang";
CARGO_RUSTFLAGS = "-Clink-arg=-fuse-ld=${mold}/bin/mold";
};
meta = {
description = "Wayland clipboard manager with fast persistent history and multi-media support";
homepage = "https://github.com/notashelf/stash";
license = lib.licenses.mpl20;
maintainers = [lib.maintainers.NotAShelf];
mainProgram = "stash";
platforms = lib.platforms.linux;
};
}

View file

@ -1,20 +1,29 @@
{
mkShell,
rust-analyzer,
rustfmt,
rustc,
clippy,
cargo,
rustfmt,
clippy,
taplo,
rust-analyzer-unwrapped,
cargo-nextest,
rustPlatform,
}:
mkShell {
name = "rust";
packages = [
rust-analyzer
rustfmt
rustc
cargo
(rustfmt.override {asNightly = true;})
clippy
cargo
rustc
taplo
rust-analyzer-unwrapped
# Additional Cargo Tooling
cargo-nextest
];
RUST_SRC_PATH = "${rustPlatform.rustLibSrc}";

3
src/clipboard/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod persist;
pub use persist::{ClipboardData, get_serving_pid, persist_clipboard};

262
src/clipboard/persist.rs Normal file
View file

@ -0,0 +1,262 @@
use std::{
process::exit,
sync::atomic::{AtomicI32, Ordering},
};
use wl_clipboard_rs::copy::{
ClipboardType,
MimeType as CopyMimeType,
Options,
PreparedCopy,
ServeRequests,
Source,
};
/// Maximum number of paste requests to serve before exiting. This (hopefully)
/// prevents runaway processes while still providing persistence.
const MAX_SERVE_REQUESTS: usize = 1000;
/// PID of the current clipboard persistence child process. Used to detect when
/// clipboard content is from our own serve process.
static SERVING_PID: AtomicI32 = AtomicI32::new(0);
/// Get the current serving PID if any. Used by the watch loop to avoid
/// duplicate persistence processes.
pub fn get_serving_pid() -> Option<i32> {
let pid = SERVING_PID.load(Ordering::SeqCst);
if pid != 0 { Some(pid) } else { None }
}
/// Result type for persistence operations.
pub type PersistenceResult<T> = Result<T, PersistenceError>;
/// Errors that can occur during clipboard persistence.
#[derive(Debug, thiserror::Error)]
pub enum PersistenceError {
#[error("Failed to prepare copy: {0}")]
PrepareFailed(String),
#[error("Failed to fork: {0}")]
ForkFailed(String),
#[error("Clipboard data too large: {0} bytes")]
DataTooLarge(usize),
#[error("Clipboard content is empty")]
EmptyContent,
#[error("No MIME types to offer")]
NoMimeTypes,
}
/// Clipboard data with all MIME types for persistence.
#[derive(Debug, Clone)]
pub struct ClipboardData {
/// The actual clipboard content.
pub content: Vec<u8>,
/// All MIME types offered by the source. Preserves order.
pub mime_types: Vec<String>,
/// The MIME type that was selected for storage.
pub selected_mime: String,
}
impl ClipboardData {
/// Create new clipboard data.
pub fn new(
content: Vec<u8>,
mime_types: Vec<String>,
selected_mime: String,
) -> Self {
Self {
content,
mime_types,
selected_mime,
}
}
/// Check if data is valid for persistence.
pub fn is_valid(&self) -> Result<(), PersistenceError> {
const MAX_SIZE: usize = 100 * 1024 * 1024; // 100MB
if self.content.is_empty() {
return Err(PersistenceError::EmptyContent);
}
if self.content.len() > MAX_SIZE {
return Err(PersistenceError::DataTooLarge(self.content.len()));
}
if self.mime_types.is_empty() {
return Err(PersistenceError::NoMimeTypes);
}
Ok(())
}
}
/// Persist clipboard data by forking a background process that serves it.
///
/// 1. Prepares a clipboard copy operation with all MIME types
/// 2. Forks a child process
/// 3. The child serves clipboard data indefinitely (until MAX_SERVE_REQUESTS)
/// 4. The parent returns immediately
///
/// # Safety
///
/// This function uses `libc::fork()` which is unsafe. The child process
/// must not modify any shared state or file descriptors.
pub unsafe fn persist_clipboard(data: ClipboardData) -> PersistenceResult<()> {
// Validate data
data.is_valid()?;
// Prepare the copy operation
let prepared = prepare_clipboard_copy(&data)?;
// Fork and serve
unsafe { fork_and_serve(prepared) }
}
/// Prepare a clipboard copy operation with all MIME types.
fn prepare_clipboard_copy(
data: &ClipboardData,
) -> PersistenceResult<PreparedCopy> {
let mut opts = Options::new();
opts.clipboard(ClipboardType::Regular);
opts.serve_requests(ServeRequests::Only(MAX_SERVE_REQUESTS));
opts.foreground(true); // we'll fork manually for better control
// Determine MIME type for the primary offer
let mime_type = if data.selected_mime.starts_with("text/") {
CopyMimeType::Text
} else {
CopyMimeType::Specific(data.selected_mime.clone())
};
// Prepare the copy
let prepared = opts
.prepare_copy(Source::Bytes(data.content.clone().into()), mime_type)
.map_err(|e| PersistenceError::PrepareFailed(e.to_string()))?;
Ok(prepared)
}
/// Fork a child process to serve clipboard data.
///
/// The child process will:
///
/// 1. Register its process ID with the self-detection module
/// 2. Serve clipboard requests until MAX_SERVE_REQUESTS
/// 3. Exit cleanly
///
/// The parent stores the child `PID` in `SERVING_PID` and returns immediately.
unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> {
// Enable automatic child reaping to prevent zombie processes
unsafe {
libc::signal(libc::SIGCHLD, libc::SIG_IGN);
}
match unsafe { libc::fork() } {
0 => {
// Child process - clear serving PID
// Look at me. I'm the server now.
SERVING_PID.store(0, Ordering::SeqCst);
serve_clipboard_child(prepared);
exit(0);
},
-1 => {
// Oops.
Err(PersistenceError::ForkFailed(
"libc::fork() returned -1".to_string(),
))
},
pid => {
// Parent process, store child PID for loop detection
log::debug!("forked clipboard persistence process (pid: {pid})");
SERVING_PID.store(pid, Ordering::SeqCst);
Ok(())
},
}
}
/// Child process entry point for serving clipboard data.
fn serve_clipboard_child(prepared: PreparedCopy) {
let pid = std::process::id() as i32;
log::debug!("clipboard persistence child process started (pid: {pid})");
// Serve clipboard requests. The PreparedCopy::serve() method blocks and
// handles all the Wayland protocol interactions internally via
// wl-clipboard-rs
match prepared.serve() {
Ok(()) => {
log::debug!("clipboard persistence: serve completed normally");
},
Err(e) => {
log::error!("clipboard persistence: serve failed: {e}");
exit(1);
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clipboard_data_validation() {
// Valid data
let valid = ClipboardData::new(
b"hello".to_vec(),
vec!["text/plain".to_string()],
"text/plain".to_string(),
);
assert!(valid.is_valid().is_ok());
// Empty content
let empty = ClipboardData::new(
vec![],
vec!["text/plain".to_string()],
"text/plain".to_string(),
);
assert!(matches!(
empty.is_valid(),
Err(PersistenceError::EmptyContent)
));
// No MIME types
let no_mimes =
ClipboardData::new(b"hello".to_vec(), vec![], "text/plain".to_string());
assert!(matches!(
no_mimes.is_valid(),
Err(PersistenceError::NoMimeTypes)
));
// Too large
let huge = ClipboardData::new(
vec![0u8; 101 * 1024 * 1024], // 101MB
vec!["text/plain".to_string()],
"text/plain".to_string(),
);
assert!(matches!(
huge.is_valid(),
Err(PersistenceError::DataTooLarge(_))
));
}
#[test]
fn test_clipboard_data_creation() {
let data = ClipboardData::new(
b"test content".to_vec(),
vec!["text/plain".to_string(), "text/html".to_string()],
"text/plain".to_string(),
);
assert_eq!(data.content, b"test content");
assert_eq!(data.mime_types.len(), 2);
assert_eq!(data.selected_mime, "text/plain");
}
}

View file

@ -1,75 +1,88 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::{Read, Write};
use crate::db::StashError;
use wl_clipboard_rs::paste::{ClipboardType, MimeType, Seat, get_contents};
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait DecodeCommand {
fn decode(
&self,
in_: impl Read,
out: impl Write,
input: Option<String>,
) -> Result<(), StashError>;
fn decode(
&self,
in_: impl Read,
out: impl Write,
input: Option<String>,
) -> Result<(), StashError>;
}
impl DecodeCommand for SqliteClipboardDb {
fn decode(
&self,
mut in_: impl Read,
mut out: impl Write,
input: Option<String>,
) -> Result<(), StashError> {
let input_str = if let Some(s) = input {
s
} else {
let mut buf = String::new();
if let Err(e) = in_.read_to_string(&mut buf) {
log::error!("Failed to read stdin for decode: {e}");
}
buf
};
fn decode(
&self,
mut in_: impl Read,
mut out: impl Write,
input: Option<String>,
) -> Result<(), StashError> {
let input_str = if let Some(s) = input {
s
} else {
let mut buf = String::new();
in_
.read_to_string(&mut buf)
.map_err(|e| StashError::DecodeRead(e.to_string().into()))?;
buf
};
// If input is empty or whitespace, treat as error and trigger fallback
if input_str.trim().is_empty() {
log::info!("No input provided to decode; relaying clipboard to stdout");
if let Ok((mut reader, _mime)) =
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
{
let mut buf = Vec::new();
if let Err(err) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard for relay: {err}");
} else {
let _ = out.write_all(&buf);
}
} else {
log::error!("Failed to get clipboard contents for relay");
}
return Ok(());
}
// Try decode as usual
match self.decode_entry(input_str.as_bytes(), &mut out, Some(input_str.clone())) {
Ok(()) => {
log::info!("Entry decoded");
}
Err(e) => {
log::error!("Failed to decode entry: {e}");
if let Ok((mut reader, _mime)) =
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
{
let mut buf = Vec::new();
if let Err(err) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard for relay: {err}");
} else {
let _ = out.write_all(&buf);
}
} else {
log::error!("Failed to get clipboard contents for relay");
}
}
}
Ok(())
// If input is empty or whitespace, treat as error and trigger fallback
if input_str.trim().is_empty() {
log::debug!("no input provided to decode; relaying clipboard to stdout");
if let Ok((mut reader, _mime)) =
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
{
let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|e| {
StashError::DecodeRead(
format!("Failed to read clipboard for relay: {e}").into(),
)
})?;
out.write_all(&buf).map_err(|e| {
StashError::DecodeWrite(
format!("Failed to write clipboard relay: {e}").into(),
)
})?;
} else {
return Err(StashError::DecodeGet(
"Failed to get clipboard contents for relay".into(),
));
}
return Ok(());
}
// Try decode as usual
match self.decode_entry(
input_str.as_bytes(),
&mut out,
Some(input_str.clone()),
) {
Ok(()) => Ok(()),
Err(e) => {
// On decode failure, relay clipboard as fallback
if let Ok((mut reader, _mime)) =
get_contents(ClipboardType::Regular, Seat::Unspecified, MimeType::Any)
{
let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|err| {
StashError::DecodeRead(
format!("Failed to read clipboard for relay: {err}").into(),
)
})?;
out.write_all(&buf).map_err(|err| {
StashError::DecodeWrite(
format!("Failed to write clipboard relay: {err}").into(),
)
})?;
Ok(())
} else {
Err(e)
}
},
}
}
}

View file

@ -1,22 +1,15 @@
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait DeleteCommand {
fn delete(&self, input: impl Read) -> Result<usize, StashError>;
fn delete(&self, input: impl Read) -> Result<usize, StashError>;
}
impl DeleteCommand for SqliteClipboardDb {
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
match self.delete_entries(input) {
Ok(deleted) => {
log::info!("Deleted {deleted} entries");
Ok(deleted)
}
Err(e) => {
log::error!("Failed to delete entries: {e}");
Err(e)
}
}
}
fn delete(&self, input: impl Read) -> Result<usize, StashError> {
let deleted = self.delete_entries(input)?;
log::info!("deleted {deleted} entries");
Ok(deleted)
}
}

66
src/commands/import.rs Normal file
View file

@ -0,0 +1,66 @@
use std::io::{self, BufRead};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb, StashError};
pub trait ImportCommand {
/// Import clipboard entries from TSV format.
fn import_tsv(
&self,
input: impl io::Read,
max_items: u64,
) -> Result<(), StashError>;
}
impl ImportCommand for SqliteClipboardDb {
fn import_tsv(
&self,
input: impl io::Read,
max_items: u64,
) -> Result<(), StashError> {
let reader = io::BufReader::new(input);
let mut imported = 0;
for (lineno, line) in reader.lines().enumerate() {
let line = line.map_err(|e| {
StashError::Store(format!("Failed to read line {lineno}: {e}").into())
})?;
let mut parts = line.splitn(2, '\t');
let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else {
return Err(StashError::Store(
format!("Malformed TSV line {lineno}: {line:?}").into(),
));
};
let Ok(_id) = id_str.parse::<u64>() else {
return Err(StashError::Store(
format!("Failed to parse id from line {lineno}: {id_str}").into(),
));
};
let entry = Entry {
contents: val.as_bytes().to_vec(),
mime: crate::mime::detect_mime(val.as_bytes()),
};
self
.conn
.execute(
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
rusqlite::params![entry.contents, entry.mime],
)
.map_err(|e| {
StashError::Store(
format!("Failed to insert entry at line {lineno}: {e}").into(),
)
})?;
imported += 1;
}
log::info!("imported {imported} records from TSV into SQLite database.");
// Trim database to max_items after import
self.trim_db(max_items)?;
log::info!("trimmed clipboard database to max_items = {max_items}");
Ok(())
}
}

View file

@ -1,14 +1,758 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::Write;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait ListCommand {
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError>;
fn list(
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError>;
}
impl ListCommand for SqliteClipboardDb {
fn list(&self, out: impl Write, preview_width: u32) -> Result<(), crate::db::StashError> {
self.list_entries(out, preview_width)?;
log::info!("Listed clipboard entries");
Ok(())
}
fn list(
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError> {
self
.list_entries(out, preview_width, include_expired, reverse)
.map(|_| ())
}
}
/// All mutable state for the TUI list view.
struct TuiState {
/// Total number of entries matching the current filter in the DB.
total: usize,
/// Global cursor position: index into the full ordered result set.
cursor: usize,
/// DB offset of `window[0]`, i.e., the first row currently loaded.
viewport_offset: usize,
/// The loaded slice of entries: `(id, preview, mime)`.
window: Vec<(i64, String, String)>,
/// How many rows the window holds (== visible list height).
window_size: usize,
/// Whether the window needs to be re-fetched from the DB.
dirty: bool,
/// Current search query. Empty string means no filter.
search_query: String,
/// Whether we're currently in search input mode.
search_mode: bool,
/// Whether to show entries in reverse order (oldest first).
reverse: bool,
/// ID of entry currently being copied.
copying_entry: Option<i64>,
}
impl TuiState {
/// Create initial state: count total rows, load the first window.
fn new(
db: &SqliteClipboardDb,
include_expired: bool,
window_size: usize,
preview_width: u32,
reverse: bool,
) -> Result<Self, StashError> {
let total = db.count_entries(include_expired, None)?;
let window = if total > 0 {
db.fetch_entries_window(
include_expired,
0,
window_size,
preview_width,
None,
reverse,
)?
} else {
Vec::new()
};
Ok(Self {
total,
cursor: 0,
viewport_offset: 0,
window,
window_size,
dirty: false,
search_query: String::new(),
search_mode: false,
reverse,
copying_entry: None,
})
}
/// Return the current search filter (`None` if empty).
fn search_filter(&self) -> Option<&str> {
if self.search_query.is_empty() {
None
} else {
Some(&self.search_query)
}
}
/// Update search query and reset cursor. Returns true if search changed.
fn set_search(&mut self, query: String) -> bool {
let changed = self.search_query != query;
if changed {
self.search_query = query;
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
changed
}
/// Clear search and reset state. Returns true if was searching.
fn clear_search(&mut self) -> bool {
let had_search = !self.search_query.is_empty();
self.search_query.clear();
self.search_mode = false;
if had_search {
self.cursor = 0;
self.viewport_offset = 0;
self.dirty = true;
}
had_search
}
/// Toggle search mode.
fn toggle_search_mode(&mut self) {
self.search_mode = !self.search_mode;
if self.search_mode {
// When entering search mode, clear query if there was one
// or start fresh
self.search_query.clear();
self.dirty = true;
}
}
/// Return the cursor position relative to the current window
/// (`window[local_cursor]` == the selected entry).
#[inline]
fn local_cursor(&self) -> usize {
self.cursor.saturating_sub(self.viewport_offset)
}
/// Return the selected `(id, preview, mime)` if any entry is selected.
fn selected_entry(&self) -> Option<&(i64, String, String)> {
if self.total == 0 {
return None;
}
self.window.get(self.local_cursor())
}
/// Move the cursor down by one, wrapping to 0 at the bottom.
fn move_down(&mut self) {
if self.total == 0 {
return;
}
self.cursor = if self.cursor + 1 >= self.total {
0
} else {
self.cursor + 1
};
self.dirty = true;
}
/// Move the cursor up by one, wrapping to `total - 1` at the top.
fn move_up(&mut self) {
if self.total == 0 {
return;
}
self.cursor = if self.cursor == 0 {
self.total - 1
} else {
self.cursor - 1
};
self.dirty = true;
}
/// Resize the window (e.g. terminal resized). Marks dirty so the
/// viewport is reloaded on the next frame.
fn resize(&mut self, new_size: usize) {
if new_size != self.window_size {
self.window_size = new_size;
self.dirty = true;
}
}
/// After a delete the total shrinks by one and the cursor may need
/// clamping. The caller is responsible for the DB deletion itself.
fn on_delete(&mut self) {
if self.total == 0 {
return;
}
self.total -= 1;
if self.total == 0 {
self.cursor = 0;
} else if self.cursor >= self.total {
self.cursor = self.total - 1;
}
self.dirty = true;
}
/// Reload the window from the DB if `dirty` is set or if the cursor
/// has drifted outside the currently loaded range.
fn sync(
&mut self,
db: &SqliteClipboardDb,
include_expired: bool,
preview_width: u32,
) -> Result<(), StashError> {
let cursor_out_of_window = self.cursor < self.viewport_offset
|| self.cursor >= self.viewport_offset + self.window.len().max(1);
if !self.dirty && !cursor_out_of_window {
return Ok(());
}
// Re-anchor the viewport so the cursor sits in the upper half when
// scrolling downward, or at a sensible position when wrapping.
let half = self.window_size / 2;
self.viewport_offset = if self.cursor >= half {
(self.cursor - half).min(self.total.saturating_sub(self.window_size))
} else {
0
};
let search = self.search_filter();
self.window = if self.total > 0 {
db.fetch_entries_window(
include_expired,
self.viewport_offset,
self.window_size,
preview_width,
search,
self.reverse,
)?
} else {
Vec::new()
};
self.dirty = false;
Ok(())
}
}
/// Query the maximum id digit-width and maximum mime byte-length across
/// all entries. This is pretty damn fast as it touches only index/metadata,
/// not blobs.
fn global_column_widths(
db: &SqliteClipboardDb,
include_expired: bool,
) -> Result<(usize, usize), StashError> {
let filter = if include_expired {
""
} else {
"WHERE (is_expired IS NULL OR is_expired = 0)"
};
let query = format!(
"SELECT COALESCE(MAX(LENGTH(CAST(id AS TEXT))), 2), \
COALESCE(MAX(LENGTH(mime)), 8) FROM clipboard {filter}"
);
let (id_w, mime_w): (i64, i64) = db
.conn
.query_row(&query, [], |r| Ok((r.get(0)?, r.get(1)?)))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok((id_w.max(2) as usize, mime_w.max(8) as usize))
}
impl SqliteClipboardDb {
#[allow(clippy::too_many_lines)]
pub fn list_tui(
&self,
preview_width: u32,
include_expired: bool,
reverse: bool,
) -> Result<(), StashError> {
use std::io::stdout;
use crossterm::{
event::{
self,
DisableMouseCapture,
EnableMouseCapture,
Event,
KeyCode,
KeyModifiers,
},
execute,
terminal::{
EnterAlternateScreen,
LeaveAlternateScreen,
disable_raw_mode,
enable_raw_mode,
},
};
use notify_rust::Notification;
use ratatui::{
Terminal,
backend::CrosstermBackend,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
};
use wl_clipboard_rs::copy::{MimeType, Options, Source};
// One-time column-width metadata (no blob reads).
let (max_id_width, max_mime_width) =
global_column_widths(self, include_expired)?;
enable_raw_mode()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
// Derive initial window size from current terminal height.
let initial_height = terminal
.size()
.map(|r| r.height.saturating_sub(2) as usize)
.unwrap_or(24);
let initial_height = initial_height.max(1);
let mut tui = TuiState::new(
self,
include_expired,
initial_height,
preview_width,
reverse,
)?;
// ratatui ListState; only tracks selection within the *window* slice.
let mut list_state = ListState::default();
if tui.total > 0 {
list_state.select(Some(0));
}
/// Accumulated actions from draining the event queue.
struct EventActions {
quit: bool,
net_down: i64, // positive=down, negative=up, 0=none
copy: bool,
delete: bool,
toggle_search: bool, // enter/exit search mode
search_input: Option<char>, // character typed in search mode
search_backspace: bool, // backspace in search mode
clear_search: bool, // clear search query (ESC in search mode)
}
/// Drain all pending key events and return what actions to perform.
/// Navigation is capped to +-1 per frame to prevent jumpy scrolling when
/// the key-repeat rate exceeds the render frame rate.
fn drain_events(tui: &TuiState) -> Result<EventActions, StashError> {
let mut actions = EventActions {
quit: false,
net_down: 0,
copy: false,
delete: false,
toggle_search: false,
search_input: None,
search_backspace: false,
clear_search: false,
};
while event::poll(std::time::Duration::from_millis(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if let Event::Key(key) = event::read()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
if tui.search_mode {
// In search mode, handle text input
match (key.code, key.modifiers) {
(KeyCode::Esc, _) => {
actions.clear_search = true;
},
(KeyCode::Enter, _) => {
actions.toggle_search = true; // exit search mode
},
(KeyCode::Backspace, _) => {
actions.search_backspace = true;
},
(KeyCode::Char(c), _) => {
actions.search_input = Some(c);
},
_ => {},
}
} else {
// Normal mode navigation commands
match (key.code, key.modifiers) {
(KeyCode::Char('q') | KeyCode::Esc, _) => actions.quit = true,
(KeyCode::Down | KeyCode::Char('j'), _) => {
// Cap at +1 per frame for smooth scrolling
if actions.net_down < 1 {
actions.net_down += 1;
}
},
(KeyCode::Up | KeyCode::Char('k'), _) => {
// Cap at -1 per frame for smooth scrolling
if actions.net_down > -1 {
actions.net_down -= 1;
}
},
(KeyCode::Enter, _) => actions.copy = true,
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
actions.delete = true;
},
(KeyCode::Char('/'), _) => actions.toggle_search = true,
_ => {},
}
}
}
}
Ok(actions)
}
let draw_frame =
|terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
tui: &mut TuiState,
list_state: &mut ListState,
max_id_width: usize,
max_mime_width: usize|
-> Result<(), StashError> {
// Reserve 2 rows for search bar when in search mode
let search_bar_height = if tui.search_mode { 2 } else { 0 };
let term_height = terminal
.size()
.map(|r| r.height.saturating_sub(2 + search_bar_height) as usize)
.unwrap_or(24)
.max(1);
tui.resize(term_height);
tui.sync(self, include_expired, preview_width)?;
if tui.total == 0 {
list_state.select(None);
} else {
list_state.select(Some(tui.local_cursor()));
}
terminal
.draw(|f| {
let area = f.area();
// Build title based on search state
let title = if tui.search_mode {
format!("Search: {}", tui.search_query)
} else if tui.search_query.is_empty() {
"Clipboard Entries (j/k/↑/↓ to move, / to search, Enter to copy, \
Shift+D to delete, q/ESC to quit)"
.to_string()
} else {
format!(
"Clipboard Entries (filtered: '{}' - {} results, / to search, \
ESC to clear, q to quit)",
tui.search_query, tui.total
)
};
let block = Block::default().title(title).borders(Borders::ALL);
let border_width = 2;
let highlight_symbol = ">";
let highlight_width = 1;
let content_width = area.width as usize - border_width;
let min_id_width = 2;
let min_mime_width = 6;
let min_preview_width = 4;
let spaces = 3;
let mut id_col = max_id_width.max(min_id_width);
let mut mime_col = max_mime_width.max(min_mime_width);
let mut preview_col = content_width
.saturating_sub(highlight_width)
.saturating_sub(id_col)
.saturating_sub(mime_col)
.saturating_sub(spaces);
if preview_col < min_preview_width {
let needed = min_preview_width - preview_col;
if mime_col > min_mime_width {
let reduce = mime_col - min_mime_width;
let take = reduce.min(needed);
mime_col -= take;
preview_col += take;
}
}
if preview_col < min_preview_width {
let needed = min_preview_width - preview_col;
if id_col > min_id_width {
let reduce = id_col - min_id_width;
let take = reduce.min(needed);
id_col -= take;
preview_col += take;
}
}
if preview_col < min_preview_width {
preview_col = min_preview_width;
}
let selected = list_state.selected();
let list_items: Vec<ListItem> = tui
.window
.iter()
.enumerate()
.map(|(i, entry)| {
let mut preview = String::new();
let mut width = 0;
for g in entry.1.graphemes(true) {
let g_width = UnicodeWidthStr::width(g);
if width + g_width > preview_col {
preview.push('…');
break;
}
preview.push_str(g);
width += g_width;
}
let mut mime = String::new();
let mut mwidth = 0;
for g in entry.2.graphemes(true) {
let g_width = UnicodeWidthStr::width(g);
if mwidth + g_width > mime_col {
mime.push('…');
break;
}
mime.push_str(g);
mwidth += g_width;
}
let mut spans = Vec::new();
let (id, preview, mime) = entry;
if Some(i) == selected {
spans.push(Span::styled(
highlight_symbol,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
format!("{id:>id_col$}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{preview:<preview_col$}"),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(" "));
spans.push(Span::styled(
format!("{mime:>mime_col$}"),
Style::default().fg(Color::Green),
));
} else {
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{id:>id_col$}")));
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{preview:<preview_col$}")));
spans.push(Span::raw(" "));
spans.push(Span::raw(format!("{mime:>mime_col$}")));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(list_items)
.block(block)
.highlight_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("");
f.render_stateful_widget(list, area, list_state);
})
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok(())
};
// Initial draw.
draw_frame(
&mut terminal,
&mut tui,
&mut list_state,
max_id_width,
max_mime_width,
)?;
let res = (|| -> Result<(), StashError> {
loop {
// Block waiting for events, then drain and process all queued input.
if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let actions = drain_events(&tui)?;
if actions.quit {
break;
}
// Handle search mode actions
if actions.toggle_search {
tui.toggle_search_mode();
}
if actions.clear_search && tui.clear_search() {
// Search was cleared, refresh count
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
if let Some(c) = actions.search_input {
let new_query = format!("{}{}", tui.search_query, c);
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
if actions.search_backspace {
let new_query = tui
.search_query
.chars()
.next_back()
.map(|_| {
tui
.search_query
.chars()
.take(tui.search_query.len() - 1)
.collect::<String>()
})
.unwrap_or_default();
if tui.set_search(new_query) {
// Search changed, refresh count and reset
tui.total =
self.count_entries(include_expired, tui.search_filter())?;
}
}
// Apply navigation (capped at ±1 per frame for smooth scrolling).
if !tui.search_mode {
if actions.net_down > 0 {
tui.move_down();
} else if actions.net_down < 0 {
tui.move_up();
}
if actions.delete
&& let Some(&(id, ..)) = tui.selected_entry()
{
self
.conn
.execute(
"DELETE FROM clipboard WHERE id = ?1",
rusqlite::params![id],
)
.map_err(|e| {
StashError::DeleteEntry(id, e.to_string().into())
})?;
tui.on_delete();
let _ = Notification::new()
.summary("Stash")
.body("Deleted entry")
.show();
}
if actions.copy
&& let Some(&(id, ..)) = tui.selected_entry()
{
if tui.copying_entry == Some(id) {
log::debug!(
"Skipping duplicate copy for entry {id} (already in \
progress)"
);
} else {
tui.copying_entry = Some(id);
match self.copy_entry(id) {
Ok((new_id, contents, mime)) => {
if new_id != id {
tui.dirty = true;
}
let opts = Options::new();
let mime_type = match mime {
Some(ref m) if m == "text/plain" => MimeType::Text,
Some(ref m) => MimeType::Specific(m.clone().clone()),
None => MimeType::Text,
};
let copy_result = opts
.copy(Source::Bytes(contents.clone().into()), mime_type);
match copy_result {
Ok(()) => {
let _ = Notification::new()
.summary("Stash")
.body("Copied entry to clipboard")
.show();
},
Err(e) => {
log::error!("failed to copy entry to clipboard: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to copy to clipboard: {e}"))
.show();
},
}
},
Err(e) => {
log::error!("failed to fetch entry {id}: {e}");
let _ = Notification::new()
.summary("Stash")
.body(&format!("Failed to fetch entry: {e}"))
.show();
},
}
tui.copying_entry = None;
}
}
}
// Redraw once after processing all accumulated input.
draw_frame(
&mut terminal,
&mut tui,
&mut list_state,
max_id_width,
max_mime_width,
)?;
}
}
Ok(())
})();
// Ignore errors during terminal restore, as we can't recover here.
let _ = disable_raw_mode();
let _ = execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
let _ = terminal.show_cursor();
res
}
}

View file

@ -1,7 +1,7 @@
pub mod decode;
pub mod delete;
pub mod import;
pub mod list;
pub mod query;
pub mod store;
pub mod watch;
pub mod wipe;

View file

@ -1,13 +1,11 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use crate::db::StashError;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait QueryCommand {
fn query_delete(&self, query: &str) -> Result<usize, StashError>;
fn query_delete(&self, query: &str) -> Result<usize, StashError>;
}
impl QueryCommand for SqliteClipboardDb {
fn query_delete(&self, query: &str) -> Result<usize, StashError> {
<SqliteClipboardDb as ClipboardDb>::delete_query(self, query)
}
fn query_delete(&self, query: &str) -> Result<usize, StashError> {
<Self as ClipboardDb>::delete_query(self, query)
}
}

View file

@ -1,32 +1,48 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb};
#[allow(clippy::too_many_arguments)]
pub trait StoreCommand {
fn store(
&self,
input: impl Read,
max_dedupe_search: u64,
max_items: u64,
state: Option<String>,
) -> Result<(), crate::db::StashError>;
fn store(
&self,
input: impl Read,
max_dedupe_search: u64,
max_items: u64,
state: Option<String>,
excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError>;
}
impl StoreCommand for SqliteClipboardDb {
fn store(
&self,
input: impl Read,
max_dedupe_search: u64,
max_items: u64,
state: Option<String>,
) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?;
log::info!("Entry deleted");
} else {
self.store_entry(input, max_dedupe_search, max_items)?;
log::info!("Entry stored");
}
Ok(())
fn store(
&self,
input: impl Read,
max_dedupe_search: u64,
max_items: u64,
state: Option<String>,
excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?;
log::info!("entry deleted");
} else {
self.store_entry(
input,
max_dedupe_search,
max_items,
Some(excluded_apps),
min_size,
max_size,
None, // no pre-computed hash for CLI store
None, // no mime types for CLI store
)?;
log::info!("entry stored");
}
Ok(())
}
}

View file

@ -1,79 +1,712 @@
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb};
use smol::Timer;
use std::io::Read;
use std::time::Duration;
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use std::{collections::BinaryHeap, hash::Hasher, io::Read, time::Duration};
use smol::Timer;
use wl_clipboard_rs::{
copy::{MimeType as CopyMimeType, Options, Source},
paste::{
ClipboardType,
MimeType as PasteMimeType,
Seat,
get_contents,
get_mime_types_ordered,
},
};
use crate::{
clipboard::{self, ClipboardData, get_serving_pid},
db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb},
hash::Fnv1aHasher,
};
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
/// Also see:
/// - <https://doc.rust-lang.org/std/cmp/struct.Reverse.html>
/// - <https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp>
/// - <https://docs.rs/ordered-float/latest/ordered_float/>
#[derive(Debug, Clone, Copy)]
struct Neg(f64);
impl Neg {
fn inner(&self) -> f64 {
self.0
}
}
impl std::cmp::PartialEq for Neg {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl std::cmp::Eq for Neg {}
impl std::cmp::PartialOrd for Neg {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::cmp::Ord for Neg {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Reverse ordering for min-heap behavior
other
.0
.partial_cmp(&self.0)
.unwrap_or(std::cmp::Ordering::Equal)
}
}
/// Min-heap for tracking entry expirations with sub-second precision.
/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior.
#[derive(Debug, Default)]
struct ExpirationQueue {
heap: BinaryHeap<(Neg, i64)>,
}
impl ExpirationQueue {
/// Create a new empty expiration queue
fn new() -> Self {
Self {
heap: BinaryHeap::new(),
}
}
/// Push a new expiration into the queue
fn push(&mut self, expires_at: f64, id: i64) {
self.heap.push((Neg(expires_at), id));
}
/// Peek at the next expiration timestamp without removing it
fn peek_next(&self) -> Option<f64> {
self.heap.peek().map(|(neg, _)| neg.inner())
}
/// Remove and return all entries that have expired by `now`
fn pop_expired(&mut self, now: f64) -> Vec<i64> {
let mut expired = Vec::new();
while let Some((neg_exp, id)) = self.heap.peek() {
let expires_at = neg_exp.inner();
if expires_at <= now {
expired.push(*id);
self.heap.pop();
} else {
break;
}
}
expired
}
/// Check if the queue is empty
fn is_empty(&self) -> bool {
self.heap.is_empty()
}
/// Get the number of entries in the queue
fn len(&self) -> usize {
self.heap.len()
}
}
/// Get clipboard contents using the source application's preferred MIME type.
///
/// See, `MimeType::Any` lets wl-clipboard-rs pick a type in arbitrary order,
/// which causes issues when applications offer multiple types (e.g. file
/// managers offering `text/uri-list` + `text/plain`, or Firefox offering
/// `text/html` + `image/png` + `text/plain`).
///
/// This queries the ordered types via [`get_mime_types_ordered`], which
/// preserves the Wayland protocol's offer order (source application's
/// preference) and then requests the first type with [`MimeType::Specific`].
///
/// The two-step approach has a theoretical race (clipboard could change between
/// the calls), but the wl-clipboard-rs API has no single-call variant that
/// respects source ordering. A race simply produces an error that the polling
/// loop handles like any other clipboard-empty/error case.
///
/// When `preference` is `"text"`, uses `MimeType::Text` directly (single call).
/// When `preference` is `"image"`, picks the first offered `image/*` type.
/// Otherwise picks the source's first offered type.
///
/// # Returns
///
/// The content reader, the selected MIME type, and ALL offered MIME
/// types.
#[expect(clippy::type_complexity)]
fn negotiate_mime_type(
preference: &str,
) -> Result<(Box<dyn Read>, String, Vec<String>), wl_clipboard_rs::paste::Error>
{
// Get all offered MIME types first (needed for persistence)
let offered =
get_mime_types_ordered(ClipboardType::Regular, Seat::Unspecified)?;
if preference == "text" {
let (reader, mime_str) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
PasteMimeType::Text,
)?;
return Ok((Box::new(reader) as Box<dyn Read>, mime_str, offered));
}
let chosen = if preference == "image" {
// Pick the first offered image type, fall back to first overall
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else {
// XXX: When preference is "any", deprioritize text/html if a more
// concrete type is available. Browsers and Electron apps put
// text/html first even for "Copy Image", but the HTML is just
// a wrapper (<img src="...">), i.e., never what the user wants in a
// clipboard manager. Prefer image/* first, then any non-html
// type, and fall back to text/html only as a last resort.
let has_image = offered.iter().any(|m| m.starts_with("image/"));
if has_image {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else if offered.first().is_some_and(|m| m == "text/html") {
offered
.iter()
.find(|m| *m != "text/html")
.or_else(|| offered.first())
} else {
offered.first()
}
};
match chosen {
Some(mime_str) => {
let (reader, actual_mime) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
PasteMimeType::Specific(mime_str),
)?;
Ok((Box::new(reader) as Box<dyn Read>, actual_mime, offered))
},
None => Err(wl_clipboard_rs::paste::Error::NoSeats),
}
}
#[allow(clippy::too_many_arguments)]
pub trait WatchCommand {
fn watch(&self, max_dedupe_search: u64, max_items: u64);
async fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
);
}
impl WatchCommand for SqliteClipboardDb {
fn watch(&self, max_dedupe_search: u64, max_items: u64) {
smol::block_on(async {
log::info!("Starting clipboard watch daemon");
async fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
persist: bool,
) {
let async_db = AsyncClipboardDb::new(self.db_path.clone());
log::info!(
"Starting clipboard watch daemon with MIME type preference: \
{mime_type_preference}"
);
// Preallocate buffer for clipboard contents
let mut last_contents: Option<Vec<u8>> = None;
let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully
// Initialize with current clipboard to avoid duplicating on startup
if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_contents = Some(buf.clone());
}
}
loop {
match get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
) {
Ok((mut reader, mime_type)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Only store if changed and not empty
if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) {
last_contents = Some(std::mem::take(&mut buf));
let mime = Some(mime_type.to_string());
let entry = Entry {
contents: last_contents.as_ref().unwrap().clone(),
mime,
};
let id = self.next_sequence();
match self.store_entry(
&entry.contents[..],
max_dedupe_search,
max_items,
) {
Ok(_) => log::info!("Stored new clipboard entry (id: {id})"),
Err(e) => log::error!("Failed to store clipboard entry: {e}"),
}
// Drop clipboard contents after storing
last_contents = None;
}
}
Err(e) => {
let error_msg = e.to_string();
if !error_msg.contains("empty") {
log::error!("Failed to get clipboard contents: {e}");
}
}
}
Timer::after(Duration::from_millis(500)).await;
}
});
if persist {
log::info!("clipboard persistence enabled");
}
// Build expiration queue from existing entries
let mut exp_queue = ExpirationQueue::new();
// Load all expirations from database asynchronously
match async_db.load_all_expirations().await {
Ok(expirations) => {
for (expires_at, id) in expirations {
exp_queue.push(expires_at, id);
}
if !exp_queue.is_empty() {
log::info!("loaded {} expirations from database", exp_queue.len());
}
},
Err(e) => {
log::warn!("failed to load expirations: {e}");
},
}
// We use hashes for comparison instead of storing full contents
let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096);
// Helper to hash clipboard contents using FNV-1a (deterministic across
// runs)
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = Fnv1aHasher::new();
hasher.write(data);
hasher.finish()
};
// Initialize with current clipboard using smart MIME negotiation
if let Ok((mut reader, ..)) = negotiate_mime_type(mime_type_preference) {
buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_hash = Some(hash_contents(&buf));
}
}
let poll_interval = Duration::from_millis(500);
loop {
// Process any pending expirations that are due now
if let Some(next_exp) = exp_queue.peek_next() {
let now = SqliteClipboardDb::now();
if next_exp <= now {
// Expired entries to process
let expired_ids = exp_queue.pop_expired(now);
for id in expired_ids {
// Verify entry still exists and get its content_hash
let expired_hash: Option<i64> =
match async_db.get_content_hash(id).await {
Ok(hash) => hash,
Err(e) => {
log::warn!("failed to get content hash for entry {id}: {e}");
None
},
};
if let Some(stored_hash) = expired_hash {
// Mark as expired
if let Err(e) = async_db.mark_expired(id).await {
log::warn!("failed to mark entry {id} as expired: {e}");
} else {
log::info!("entry {id} marked as expired");
}
// Check if this expired entry is currently in the clipboard
if let Ok((mut reader, ..)) =
negotiate_mime_type(mime_type_preference)
{
let mut current_buf = Vec::new();
if reader.read_to_end(&mut current_buf).is_ok()
&& !current_buf.is_empty()
{
let current_hash = hash_contents(&current_buf);
// Convert stored i64 to u64 for comparison (preserves bit
// pattern)
if current_hash == stored_hash as u64 {
// Clear the clipboard since expired content is still
// there
let mut opts = Options::new();
opts
.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
if opts
.copy(
Source::Bytes(Vec::new().into()),
CopyMimeType::Autodetect,
)
.is_ok()
{
log::info!(
"cleared clipboard containing expired entry {id}"
);
last_hash = None; // reset tracked hash
} else {
log::warn!(
"failed to clear clipboard for expired entry {id}"
);
}
}
}
}
}
}
}
}
// Normal clipboard polling (always run, even when expirations are
// pending)
match negotiate_mime_type(mime_type_preference) {
Ok((mut reader, _mime_type, _all_mimes)) => {
buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("failed to read clipboard contents: {e}");
Timer::after(Duration::from_millis(500)).await;
continue;
}
// Only store if changed and not empty
if !buf.is_empty() {
let current_hash = hash_contents(&buf);
if last_hash != Some(current_hash) {
// Clone buf for the async operation since it needs 'static
let buf_clone = buf.clone();
#[allow(clippy::cast_possible_wrap)]
let content_hash = Some(current_hash as i64);
// Clone data for persistence after successful store
let buf_for_persist = buf.clone();
let mime_types_for_persist = _all_mimes.clone();
let selected_mime = _mime_type.clone();
match async_db
.store_entry(
buf_clone,
max_dedupe_search,
max_items,
Some(excluded_apps.to_vec()),
min_size,
max_size,
content_hash,
Some(mime_types_for_persist.clone()),
)
.await
{
Ok(id) => {
log::info!("stored new clipboard entry (id: {id})");
last_hash = Some(current_hash);
// Persist clipboard: fork child to serve data
// This keeps the clipboard alive when source app closes
// Check if we're already serving to avoid duplicate processes
if persist && get_serving_pid().is_none() {
let clipboard_data = ClipboardData::new(
buf_for_persist,
mime_types_for_persist,
selected_mime,
);
// Validate and persist in blocking task
if clipboard_data.is_valid().is_ok() {
smol::spawn(async move {
// Use blocking task for fork operation
let result = smol::unblock(move || unsafe {
clipboard::persist_clipboard(clipboard_data)
})
.await;
if let Err(e) = result {
log::debug!("clipboard persistence failed: {e}");
}
})
.detach();
}
} else if persist {
log::trace!(
"Already serving clipboard, skipping persistence fork"
);
}
// Set expiration if configured
if let Some(duration) = expire_after {
let expires_at =
SqliteClipboardDb::now() + duration.as_secs_f64();
if let Err(e) =
async_db.set_expiration(id, expires_at).await
{
log::warn!(
"Failed to set expiration for entry {id}: {e}"
);
} else {
exp_queue.push(expires_at, id);
}
}
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(crate::db::StashError::Store(ref msg))
if msg.contains("Excluded by app filter") =>
{
log::info!("clipboard entry excluded by app filter");
last_hash = Some(current_hash);
},
Err(e) => {
log::error!("failed to store clipboard entry: {e}");
last_hash = Some(current_hash);
},
}
}
}
},
Err(e) => {
let error_msg = e.to_string();
if !error_msg.contains("empty") {
log::error!("failed to get clipboard contents: {e}");
}
},
}
// Calculate sleep time: min of poll interval and time until next
// expiration
let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() {
let now = SqliteClipboardDb::now();
let time_to_exp = (next_exp - now).max(0.0);
poll_interval.min(Duration::from_secs_f64(time_to_exp))
} else {
poll_interval
};
Timer::after(sleep_duration).await;
}
}
}
/// Given ordered offers and a preference, return the
/// chosen MIME type. This mirrors the selection logic in
/// [`negotiate_mime_type`] without requiring a Wayland connection.
#[cfg(test)]
fn pick_mime<'a>(
offered: &'a [String],
preference: &str,
) -> Option<&'a String> {
if preference == "image" {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else {
let has_image = offered.iter().any(|m| m.starts_with("image/"));
if has_image {
offered
.iter()
.find(|m| m.starts_with("image/"))
.or_else(|| offered.first())
} else if offered.first().is_some_and(|m| m == "text/html") {
offered
.iter()
.find(|m| *m != "text/html")
.or_else(|| offered.first())
} else {
offered.first()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pick_first_offered() {
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
}
#[test]
fn test_pick_image_preference_finds_image() {
let offered = vec![
"text/html".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
}
#[test]
fn test_pick_image_preference_falls_back() {
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
// No image types offered — falls back to first
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
}
#[test]
fn test_pick_empty_offered() {
let offered: Vec<String> = vec![];
assert!(pick_mime(&offered, "any").is_none());
}
#[test]
fn test_pick_image_over_html_firefox_copy_image() {
// Firefox "Copy Image" offers html first, then image, then text.
// We should pick the image, not the html wrapper.
let offered = vec![
"text/html".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
}
#[test]
fn test_pick_image_over_html_electron() {
// Electron apps also put text/html before image types
let offered = vec!["text/html".to_string(), "image/jpeg".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/jpeg");
}
#[test]
fn test_pick_html_fallback_when_only_html() {
// When text/html is the only type, pick it
let offered = vec!["text/html".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
}
#[test]
fn test_pick_text_over_html_when_no_image() {
// Rich text copy: html + plain, no image — prefer plain text
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
}
#[test]
fn test_pick_file_manager_uri_list_first() {
// File managers typically offer uri-list first
let offered = vec!["text/uri-list".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
}
/// Test that "text" preference is handled separately from pick_mime logic.
/// Documents that "text" preference uses PasteMimeType::Text directly
/// without querying MIME type ordering. This is functionally a regression
/// test for `negotiate_mime_type()`, which is load bearing, to ensure that
/// we don't mess it up.
#[test]
fn test_text_preference_behavior() {
// When preference is "text", negotiate_mime_type() should:
// 1. Use PasteMimeType::Text directly (no ordering query via
// get_mime_types_ordered)
// 2. Return content with text/plain MIME type
//
// Note: "text" is NOT passed to pick_mime() - it's handled separately
// in negotiate_mime_type() before the pick_mime logic.
// This test documents the separation of concerns.
let offered = vec![
"text/html".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
// pick_mime is only called for "image" and "any" preferences
// "text" goes through a different code path
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
}
/// Test MIME type selection priority for "any" preference with multiple
/// types. Documents that:
/// 1. Image types are preferred over text/html
/// 2. Non-html text types are preferred over text/html
/// 3. First offered type is used when no special cases match
#[test]
fn test_any_preference_selection_priority() {
// Priority 1: Image over HTML
let offered = vec!["text/html".to_string(), "image/png".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
// Priority 2: Plain text over HTML
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
// Priority 3: First type when no special handling
let offered =
vec!["application/json".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "application/json");
}
/// Test "image" preference behavior.
/// Documents that:
/// 1. First image/* type is selected
/// 2. Falls back to first type if no images
#[test]
fn test_image_preference_selection_behavior() {
// Multiple images - pick first one
let offered = vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/jpeg");
// No images - fall back to first
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
}
/// Test edge case: text/html as only option.
/// Documents that text/html is used when it's the only type available.
#[test]
fn test_html_fallback_as_only_option() {
let offered = vec!["text/html".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
}
/// Test complex Firefox scenario with all MIME types.
/// Documents expected behavior when source offers many types.
#[test]
fn test_firefox_copy_image_all_types() {
// Firefox "Copy Image" offers:
// text/html, text/_moz_htmlcontext, text/_moz_htmlinfo,
// image/png, image/bmp, image/x-bmp, image/x-ico,
// text/ico, application/ico, image/ico, image/icon,
// text/icon, image/x-win-bitmap, image/x-win-bmp,
// image/x-icon, text/plain
let offered = vec![
"text/html".to_string(),
"text/_moz_htmlcontext".to_string(),
"image/png".to_string(),
"image/bmp".to_string(),
"text/plain".to_string(),
];
// "any" should pick image/png (first image, skipping HTML)
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
// "image" should pick image/png
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
}
/// Test complex Electron app scenario.
#[test]
fn test_electron_app_mime_types() {
// Electron apps often offer: text/html, image/png, text/plain
let offered = vec![
"text/html".to_string(),
"image/png".to_string(),
"text/plain".to_string(),
];
assert_eq!(pick_mime(&offered, "any").unwrap(), "image/png");
assert_eq!(pick_mime(&offered, "image").unwrap(), "image/png");
}
/// Test that the function handles empty offers correctly.
/// Documents that empty offers result in an error (NoSeats equivalent).
#[test]
fn test_empty_offers_behavior() {
let offered: Vec<String> = vec![];
assert!(pick_mime(&offered, "any").is_none());
assert!(pick_mime(&offered, "image").is_none());
assert!(pick_mime(&offered, "text").is_none());
}
/// Test file manager behavior with URI lists.
#[test]
fn test_file_manager_uri_list_behavior() {
// File managers typically offer: text/uri-list, text/plain,
// x-special/gnome-copied-files
let offered = vec![
"text/uri-list".to_string(),
"text/plain".to_string(),
"x-special/gnome-copied-files".to_string(),
];
// "any" should pick text/uri-list (first)
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/uri-list");
// "image" should fall back to text/uri-list
assert_eq!(pick_mime(&offered, "image").unwrap(), "text/uri-list");
}
}

View file

@ -1,15 +0,0 @@
use crate::db::{ClipboardDb, SqliteClipboardDb};
use crate::db::StashError;
pub trait WipeCommand {
fn wipe(&self) -> Result<(), StashError>;
}
impl WipeCommand for SqliteClipboardDb {
fn wipe(&self) -> Result<(), StashError> {
self.wipe_db()?;
log::info!("Database wiped");
Ok(())
}
}

File diff suppressed because it is too large Load diff

375
src/db/nonblocking.rs Normal file
View file

@ -0,0 +1,375 @@
use std::path::PathBuf;
use rusqlite::OptionalExtension;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
/// Async wrapper for database operations that runs blocking operations
/// on a thread pool to avoid blocking the async runtime. Since
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
/// new connection for each operation.
pub struct AsyncClipboardDb {
db_path: PathBuf,
}
impl AsyncClipboardDb {
pub fn new(db_path: PathBuf) -> Self {
Self { db_path }
}
#[expect(clippy::too_many_arguments)]
pub async fn store_entry(
&self,
data: Vec<u8>,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: Option<Vec<String>>,
min_size: Option<usize>,
max_size: usize,
content_hash: Option<i64>,
mime_types: Option<Vec<String>>,
) -> Result<i64, StashError> {
let path = self.db_path.clone();
blocking::unblock(move || {
let db = Self::open_db_internal(&path)?;
db.store_entry(
std::io::Cursor::new(data),
max_dedupe_search,
max_items,
excluded_apps.as_deref(),
min_size,
max_size,
content_hash,
mime_types.as_deref(),
)
})
.await
}
pub async fn set_expiration(
&self,
id: i64,
expires_at: f64,
) -> Result<(), StashError> {
let path = self.db_path.clone();
blocking::unblock(move || {
let db = Self::open_db_internal(&path)?;
db.set_expiration(id, expires_at)
})
.await
}
pub async fn load_all_expirations(
&self,
) -> Result<Vec<(f64, i64)>, StashError> {
let path = self.db_path.clone();
blocking::unblock(move || {
let db = Self::open_db_internal(&path)?;
let mut stmt = db
.conn
.prepare(
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut expirations = Vec::new();
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
{
let exp = row
.get::<_, f64>(0)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let id = row
.get::<_, i64>(1)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
expirations.push((exp, id));
}
Ok(expirations)
})
.await
}
pub async fn get_content_hash(
&self,
id: i64,
) -> Result<Option<i64>, StashError> {
let path = self.db_path.clone();
blocking::unblock(move || {
let db = Self::open_db_internal(&path)?;
let result: Option<i64> = db
.conn
.query_row(
"SELECT content_hash FROM clipboard WHERE id = ?1",
[id],
|row| row.get(0),
)
.optional()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
Ok(result)
})
.await
}
pub async fn mark_expired(&self, id: i64) -> Result<(), StashError> {
let path = self.db_path.clone();
blocking::unblock(move || {
let db = Self::open_db_internal(&path)?;
db.conn
.execute("UPDATE clipboard SET is_expired = 1 WHERE id = ?1", [id])
.map_err(|e| StashError::Store(e.to_string().into()))?;
Ok(())
})
.await
}
fn open_db_internal(path: &PathBuf) -> Result<SqliteClipboardDb, StashError> {
let conn = rusqlite::Connection::open(path).map_err(|e| {
StashError::Store(format!("Failed to open database: {e}").into())
})?;
SqliteClipboardDb::new(conn, path.clone())
}
}
impl Clone for AsyncClipboardDb {
fn clone(&self) -> Self {
Self {
db_path: self.db_path.clone(),
}
}
}
#[cfg(test)]
mod tests {
use std::{collections::HashSet, hash::Hasher};
use tempfile::tempdir;
use super::*;
use crate::hash::Fnv1aHasher;
fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) {
let temp_dir = tempdir().expect("Failed to create temp dir");
let db_path = temp_dir.path().join("test.db");
// Create initial database
{
let conn =
rusqlite::Connection::open(&db_path).expect("Failed to open database");
crate::db::SqliteClipboardDb::new(conn, db_path.clone())
.expect("Failed to create database");
}
let async_db = AsyncClipboardDb::new(db_path);
(async_db, temp_dir)
}
#[test]
fn test_async_store_entry() {
smol::block_on(async {
let (async_db, _temp_dir) = setup_test_db();
let data = b"async test data";
let id = async_db
.store_entry(
data.to_vec(),
100,
1000,
None,
None,
5_000_000,
None,
None,
)
.await
.expect("Failed to store entry");
assert!(id > 0, "Should return positive id");
// Verify it was stored by checking content hash
let hash = async_db
.get_content_hash(id)
.await
.expect("Failed to get hash")
.expect("Hash should exist");
// Calculate expected hash
let mut hasher = Fnv1aHasher::new();
hasher.write(data);
let expected_hash = hasher.finish() as i64;
assert_eq!(hash, expected_hash, "Stored hash should match");
});
}
#[test]
fn test_async_set_expiration_and_load() {
smol::block_on(async {
let (async_db, _temp_dir) = setup_test_db();
let data = b"expiring entry";
let id = async_db
.store_entry(
data.to_vec(),
100,
1000,
None,
None,
5_000_000,
None,
None,
)
.await
.expect("Failed to store entry");
let expires_at = 1234567890.5;
async_db
.set_expiration(id, expires_at)
.await
.expect("Failed to set expiration");
// Load all expirations
let expirations = async_db
.load_all_expirations()
.await
.expect("Failed to load expirations");
assert_eq!(expirations.len(), 1, "Should have one expiration");
assert!(
(expirations[0].0 - expires_at).abs() < 0.001,
"Expiration time should match"
);
assert_eq!(expirations[0].1, id, "Expiration id should match");
});
}
#[test]
fn test_async_mark_expired() {
smol::block_on(async {
let (async_db, _temp_dir) = setup_test_db();
let data = b"entry to expire";
let id = async_db
.store_entry(
data.to_vec(),
100,
1000,
None,
None,
5_000_000,
None,
None,
)
.await
.expect("Failed to store entry");
async_db
.mark_expired(id)
.await
.expect("Failed to mark as expired");
// Load expirations, this should be empty since entry is now marked
// expired
let expirations = async_db
.load_all_expirations()
.await
.expect("Failed to load expirations");
assert!(
expirations.is_empty(),
"Expired entries should not be loaded"
);
});
}
#[test]
fn test_async_get_content_hash_not_found() {
smol::block_on(async {
let (async_db, _temp_dir) = setup_test_db();
let hash = async_db
.get_content_hash(999999)
.await
.expect("Should not fail on non-existent entry");
assert!(hash.is_none(), "Hash should be None for non-existent entry");
});
}
#[test]
fn test_async_clone() {
let (async_db, _temp_dir) = setup_test_db();
let cloned = async_db.clone();
smol::block_on(async {
// Both should work independently
let data = b"clone test";
let id1 = async_db
.store_entry(
data.to_vec(),
100,
1000,
None,
None,
5_000_000,
None,
None,
)
.await
.expect("Failed with original");
let id2 = cloned
.store_entry(
data.to_vec(),
100,
1000,
None,
None,
5_000_000,
None,
None,
)
.await
.expect("Failed with clone");
assert_ne!(id1, id2, "Should store as separate entries");
});
}
#[test]
fn test_async_concurrent_operations() {
smol::block_on(async {
let (async_db, _temp_dir) = setup_test_db();
// Spawn multiple concurrent store operations
let futures: Vec<_> = (0..5)
.map(|i| {
let db = async_db.clone();
let data = format!("concurrent test {}", i).into_bytes();
smol::spawn(async move {
db.store_entry(data, 100, 1000, None, None, 5_000_000, None, None)
.await
})
})
.collect();
let results: Result<Vec<_>, _> = futures::future::join_all(futures)
.await
.into_iter()
.collect();
let ids = results.expect("All stores should succeed");
assert_eq!(ids.len(), 5, "Should have 5 entries");
// All IDs should be unique
let unique_ids: HashSet<_> = ids.iter().collect();
assert_eq!(unique_ids.len(), 5, "All IDs should be unique");
});
}
}

101
src/hash.rs Normal file
View file

@ -0,0 +1,101 @@
/// FNV-1a hasher for deterministic hashing across process runs.
///
/// Unlike `std::collections::hash_map::DefaultHasher` (which uses SipHash
/// with a random seed), this produces stable hashes suitable for persistent
/// storage and cross-process comparison.
///
/// # Example
///
/// ```
/// use std::hash::Hasher;
///
/// use stash::hash::Fnv1aHasher;
///
/// let mut hasher = Fnv1aHasher::new();
/// hasher.write(b"hello");
/// let hash = hasher.finish();
/// ```
#[derive(Clone, Copy, Debug)]
pub struct Fnv1aHasher {
state: u64,
}
impl Fnv1aHasher {
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
const FNV_PRIME: u64 = 0x100000001B3;
/// Creates a new hasher initialized with the FNV-1a offset basis.
#[must_use]
pub fn new() -> Self {
Self {
state: Self::FNV_OFFSET,
}
}
}
impl Default for Fnv1aHasher {
fn default() -> Self {
Self::new()
}
}
impl std::hash::Hasher for Fnv1aHasher {
fn write(&mut self, bytes: &[u8]) {
for byte in bytes {
self.state ^= u64::from(*byte);
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
}
}
fn finish(&self) -> u64 {
self.state
}
}
#[cfg(test)]
mod tests {
use std::hash::Hasher;
use super::*;
#[test]
fn test_fnv1a_basic() {
let mut hasher = Fnv1aHasher::new();
hasher.write(b"hello");
// FNV-1a hash for "hello" (little-endian u64)
assert_eq!(hasher.finish(), 0xA430D84680AABD0B);
}
#[test]
fn test_fnv1a_empty() {
let hasher = Fnv1aHasher::new();
// Empty input should return offset basis
assert_eq!(hasher.finish(), Fnv1aHasher::FNV_OFFSET);
}
#[test]
fn test_fnv1a_deterministic() {
// Same input must produce same hash
let mut h1 = Fnv1aHasher::new();
let mut h2 = Fnv1aHasher::new();
h1.write(b"test data");
h2.write(b"test data");
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_default_trait() {
let h1 = Fnv1aHasher::new();
let h2 = Fnv1aHasher::default();
assert_eq!(h1.finish(), h2.finish());
}
#[test]
fn test_copy_trait() {
let mut hasher = Fnv1aHasher::new();
hasher.write(b"data");
let copied = hasher;
// Both should have same state after copy
assert_eq!(hasher.finish(), copied.finish());
}
}

View file

@ -1,40 +0,0 @@
use crate::db::{Entry, SqliteClipboardDb, detect_mime};
use log::{error, info};
use std::io::{self, BufRead};
pub trait ImportCommand {
fn import_tsv(&self, input: impl io::Read);
}
impl ImportCommand for SqliteClipboardDb {
fn import_tsv(&self, input: impl io::Read) {
let reader = io::BufReader::new(input);
let mut imported = 0;
for line in reader.lines().map_while(Result::ok) {
let mut parts = line.splitn(2, '\t');
if let (Some(id_str), Some(val)) = (parts.next(), parts.next()) {
if let Ok(_id) = id_str.parse::<u64>() {
let entry = Entry {
contents: val.as_bytes().to_vec(),
mime: detect_mime(val.as_bytes()),
};
match self.conn.execute(
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)",
rusqlite::params![entry.contents, entry.mime],
) {
Ok(_) => {
imported += 1;
info!("Imported entry from TSV");
}
Err(e) => error!("Failed to insert entry: {e}"),
}
} else {
error!("Failed to parse id from line: {id_str}");
}
} else {
error!("Malformed TSV line: {line:?}");
}
}
info!("Imported {imported} records from TSV into SQLite database.");
}
}

View file

@ -1,229 +1,487 @@
mod clipboard;
mod commands;
mod db;
mod hash;
mod mime;
mod multicall;
use std::{
env,
io::{self},
path::PathBuf,
process,
env,
io::{self, IsTerminal},
path::PathBuf,
time::Duration,
};
use clap::{CommandFactory, Parser, Subcommand};
use color_eyre::eyre;
use humantime::parse_duration;
use inquire::Confirm;
mod commands;
mod db;
mod import;
// While the module is named "wayland", the Wayland module is *strictly* for the
// use-toplevel feature as it requires some low-level wayland crates that are
// not required *by default*. The module is named that way because "toplevel"
// sounded too silly. Stash is strictly a Wayland clipboard manager.
#[cfg(feature = "use-toplevel")] mod wayland;
use crate::commands::decode::DecodeCommand;
use crate::commands::delete::DeleteCommand;
use crate::commands::list::ListCommand;
use crate::commands::query::QueryCommand;
use crate::commands::store::StoreCommand;
use crate::commands::watch::WatchCommand;
use crate::commands::wipe::WipeCommand;
use crate::import::ImportCommand;
use crate::{
commands::{
decode::DecodeCommand,
delete::DeleteCommand,
import::ImportCommand,
list::ListCommand,
query::QueryCommand,
store::StoreCommand,
watch::WatchCommand,
},
db::{ClipboardDb, DEFAULT_MAX_ENTRY_SIZE},
};
#[derive(Parser)]
#[command(name = "stash")]
#[command(about = "Wayland clipboard manager", version)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
#[command(subcommand)]
command: Option<Command>,
#[arg(long, default_value_t = 750)]
max_items: u64,
/// Maximum number of clipboard entries to keep
#[arg(long, default_value_t = u64::MAX)]
max_items: u64,
#[arg(long, default_value_t = 100)]
max_dedupe_search: u64,
/// Number of recent entries to check for duplicates when storing new
/// clipboard data.
#[arg(long, default_value_t = 20)]
max_dedupe_search: u64,
#[arg(long, default_value_t = 100)]
preview_width: u32,
/// Minimum size (in bytes) for clipboard entries. Entries smaller than this
/// will not be stored.
#[arg(long, env = "STASH_MIN_SIZE")]
min_size: Option<usize>,
#[arg(long)]
db_path: Option<PathBuf>,
/// Maximum size (in bytes) for clipboard entries. Entries larger than this
/// will not be stored. Defaults to 5MB.
#[arg(long, default_value_t = DEFAULT_MAX_ENTRY_SIZE, env = "STASH_MAX_SIZE")]
max_size: usize,
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
/// Maximum width (in characters) for clipboard entry previews in list
/// output.
#[arg(long, default_value_t = 100)]
preview_width: u32,
/// Path to the `SQLite` clipboard database file.
#[arg(long, env = "STASH_DB_PATH")]
db_path: Option<PathBuf>,
/// Application names to exclude from clipboard history
#[cfg(feature = "use-toplevel")]
#[arg(long, value_delimiter = ',', env = "STASH_EXCLUDED_APPS")]
excluded_apps: Vec<String>,
/// Ask for confirmation before destructive operations
#[arg(long)]
ask: bool,
#[command(flatten)]
verbosity: clap_verbosity_flag::Verbosity,
}
#[derive(Subcommand)]
enum Command {
/// Store clipboard contents
Store,
/// Store clipboard contents
Store,
/// List clipboard history
List {
/// Output format: "tsv" (default) or "json"
#[arg(long, value_parser = ["tsv", "json"])]
format: Option<String>,
},
/// List clipboard history
List {
/// Output format: "tsv" (default) or "json"
#[arg(long, value_parser = ["tsv", "json"])]
format: Option<String>,
/// Decode and output clipboard entry by id
Decode { input: Option<String> },
/// Show only expired entries (diagnostic, does not remove them)
#[arg(long)]
expired: bool,
/// 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.
Delete {
/// Id or query string
arg: Option<String>,
/// Reverse the order of entries (oldest first instead of newest first)
#[arg(long)]
reverse: bool,
},
/// Explicitly specify type: "id" or "query"
#[arg(long, value_parser = ["id", "query"])]
r#type: Option<String>,
},
/// Decode and output clipboard entry by id
Decode { input: Option<String> },
/// Wipe all clipboard history
Wipe,
/// 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.
Delete {
/// Id or query string
arg: Option<String>,
/// Import clipboard data from stdin (default: TSV format)
Import {
/// Explicitly specify format: "tsv" (default)
#[arg(long, value_parser = ["tsv"])]
r#type: Option<String>,
},
/// Explicitly specify type: "id" or "query"
#[arg(long, value_parser = ["id", "query"])]
r#type: Option<String>,
/// Watch clipboard for changes and store automatically
Watch,
/// Ask for confirmation before deleting
#[arg(long)]
ask: bool,
},
/// Database management operations
Db {
#[command(subcommand)]
action: DbAction,
},
/// Import clipboard data from stdin (default: TSV format)
Import {
/// Explicitly specify format: "tsv" (default)
#[arg(long, value_parser = ["tsv"])]
r#type: Option<String>,
/// Ask for confirmation before importing
#[arg(long)]
ask: bool,
},
/// Start a process to watch clipboard for changes and store automatically.
Watch {
/// Expire new entries after duration (e.g., "3s", "500ms", "1h30m").
#[arg(long, value_parser = parse_duration)]
expire_after: Option<Duration>,
/// MIME type preference for clipboard reading.
#[arg(short = 't', long, default_value = "any")]
mime_type: String,
/// Persist clipboard contents after the source application closes.
#[arg(long)]
persist: bool,
},
}
fn report_error<T>(result: Result<T, impl std::fmt::Display>, context: &str) -> Option<T> {
match result {
Ok(val) => Some(val),
Err(e) => {
log::error!("{context}: {e}");
None
}
#[derive(Subcommand)]
enum DbAction {
/// Wipe database entries
Wipe {
/// Only wipe expired entries instead of all entries
#[arg(long)]
expired: bool,
/// Ask for confirmation before wiping
#[arg(long)]
ask: bool,
},
/// Optimize database using VACUUM
Vacuum,
/// Show database statistics
Stats,
}
fn report_error<T>(
result: Result<T, impl std::fmt::Display>,
context: &str,
) -> Option<T> {
match result {
Ok(val) => Some(val),
Err(e) => {
log::error!("{context}: {e}");
None
},
}
}
fn confirm(prompt: &str) -> bool {
Confirm::new(prompt)
.with_default(false)
.prompt()
.unwrap_or_else(|e| {
log::error!("confirmation prompt failed: {e}");
false
})
}
#[allow(clippy::too_many_lines)] // whatever
fn main() -> eyre::Result<()> {
color_eyre::install()?;
// Check if we're being called as a multicall binary
//
// NOTE: We cannot use clap's multicall here because it requires the main
// command to have no arguments (only subcommands), but our Cli has global
// arguments like --max-items, --db-path, etc. Instead, we manually detect
// the invocation name and route appropriately. While this is ugly, it's
// seemingly the only option.
let program_name = env::args().next().map(|s| {
PathBuf::from(s)
.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(());
}
}
}
fn main() {
smol::block_on(async {
let cli = Cli::parse();
env_logger::Builder::new()
.filter_level(cli.verbosity.into())
.init();
// Normal CLI handling
smol::block_on(async {
let cli = Cli::parse();
env_logger::Builder::new()
.filter_level(cli.verbosity.into())
.init();
let db_path = cli.db_path.unwrap_or_else(|| {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("stash")
.join("db")
});
let db_path = match cli.db_path {
Some(path) => path,
None => {
let cache_dir = dirs::cache_dir().ok_or_else(|| {
eyre::eyre!(
"Could not determine cache directory. Set --db-path or \
$STASH_DB_PATH explicitly."
)
})?;
cache_dir.join("stash").join("db")
},
};
if let Some(parent) = db_path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::error!("Failed to create database directory: {e}");
process::exit(1);
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = rusqlite::Connection::open(&db_path)?;
let db = db::SqliteClipboardDb::new(conn, db_path)?;
match cli.command {
Some(Command::Store) => {
let state = env::var("STASH_CLIPBOARD_STATE").ok();
report_error(
db.store(
io::stdin(),
cli.max_dedupe_search,
cli.max_items,
state,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
cli.min_size,
cli.max_size,
),
"failed to store entry",
);
},
Some(Command::List {
format,
expired,
reverse,
}) => {
match format.as_deref() {
Some("tsv") => {
report_error(
db.list(io::stdout(), cli.preview_width, expired, reverse),
"failed to list entries",
);
},
Some("json") => {
match db.list_json(expired, reverse) {
Ok(json) => {
println!("{json}");
},
Err(e) => {
log::error!("failed to list entries as JSON: {e}");
},
}
},
Some(other) => {
log::error!("unsupported format: {other}");
},
None => {
if std::io::stdout().is_terminal() {
report_error(
db.list_tui(cli.preview_width, expired, reverse),
"failed to list entries in TUI",
);
} else {
report_error(
db.list(io::stdout(), cli.preview_width, expired, reverse),
"failed to list entries",
);
}
},
}
},
Some(Command::Decode { input }) => {
report_error(
db.decode(io::stdin(), io::stdout(), input),
"failed to decode entry",
);
},
Some(Command::Delete { arg, r#type, ask }) => {
let mut should_proceed = true;
if ask {
should_proceed =
confirm("Are you sure you want to delete clipboard entries?");
let conn = rusqlite::Connection::open(&db_path).unwrap_or_else(|e| {
log::error!("Failed to open SQLite database: {e}");
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 {
Some(Command::Store) => {
let state = env::var("STASH_CLIPBOARD_STATE").ok();
if !should_proceed {
log::info!("aborted by user.");
}
}
if should_proceed {
match (arg, r#type.as_deref()) {
(Some(s), Some("id")) => {
if let Ok(id) = s.parse::<u64>() {
use std::io::Cursor;
report_error(
db.store(io::stdin(), cli.max_dedupe_search, cli.max_items, state),
"Failed to store entry",
db.delete(Cursor::new(format!("{id}\n"))),
"Failed to delete entry by id",
);
}
Some(Command::List { format }) => {
let format = format.as_deref().unwrap_or("tsv");
match format {
"tsv" => {
report_error(
db.list(io::stdout(), cli.preview_width),
"Failed to list entries",
);
}
"json" => {
// Implement JSON output
match db.list_json() {
Ok(json) => {
println!("{json}");
}
Err(e) => {
log::error!("Failed to list entries as JSON: {e}");
}
}
}
_ => {
log::error!("Unsupported format: {format}");
}
}
}
Some(Command::Decode { input }) => {
report_error(
db.decode(io::stdin(), io::stdout(), input),
"Failed to decode entry",
);
}
Some(Command::Delete { arg, r#type }) => match (arg, r#type.as_deref()) {
(Some(s), Some("id")) => {
if let Ok(id) = s.parse::<u64>() {
use std::io::Cursor;
report_error(
db.delete(Cursor::new(format!("{id}\n"))),
"Failed to delete entry by id",
);
} else {
log::error!("Argument is not a valid id");
}
}
(Some(s), Some("query")) => {
report_error(db.query_delete(&s), "Failed to delete entry by query");
}
(Some(s), None) => {
if let Ok(id) = s.parse::<u64>() {
use std::io::Cursor;
report_error(
db.delete(Cursor::new(format!("{id}\n"))),
"Failed to delete entry by id",
);
} else {
report_error(db.query_delete(&s), "Failed to delete entry by query");
}
}
(None, _) => {
report_error(db.delete(io::stdin()), "Failed to delete entry from stdin");
}
(_, Some(_)) => {
log::error!("Unknown type for --type. Use \"id\" or \"query\".");
}
} else {
log::error!("argument is not a valid id");
}
},
Some(Command::Wipe) => {
report_error(db.wipe(), "Failed to wipe database");
}
Some(Command::Import { r#type }) => {
// Default format is TSV (Cliphist compatible)
let format = r#type.as_deref().unwrap_or("tsv");
match format {
"tsv" => {
db.import_tsv(io::stdin());
}
_ => {
log::error!("Unsupported import format: {format}");
}
}
}
Some(Command::Watch) => {
db.watch(cli.max_dedupe_search, cli.max_items);
}
None => {
if let Err(e) = Cli::command().print_help() {
eprintln!("Failed to print help: {e}");
}
println!();
}
(Some(s), Some("query")) => {
report_error(
db.query_delete(&s),
"failed to delete entry by query",
);
},
(Some(s), None) => {
if let Ok(id) = s.parse::<u64>() {
use std::io::Cursor;
report_error(
db.delete(Cursor::new(format!("{id}\n"))),
"failed to delete entry by id",
);
} else {
report_error(
db.query_delete(&s),
"failed to delete entry by query",
);
}
},
(None, _) => {
report_error(
db.delete(io::stdin()),
"failed to delete entry from stdin",
);
},
(_, Some(_)) => {
log::error!("unknown type for --type. Use \"id\" or \"query\".");
},
}
}
});
},
Some(Command::Db { action }) => {
match action {
DbAction::Wipe { expired, ask } => {
let mut should_proceed = true;
if ask {
let message = if expired {
"Are you sure you want to wipe all expired clipboard entries?"
} else {
"Are you sure you want to wipe ALL clipboard history?"
};
should_proceed = confirm(message);
if !should_proceed {
log::info!("db wipe command aborted by user.");
}
}
if should_proceed {
if expired {
match db.cleanup_expired() {
Ok(count) => {
log::info!("wiped {count} expired entries");
},
Err(e) => {
log::error!("failed to wipe expired entries: {e}");
},
}
} else {
report_error(db.wipe_db(), "failed to wipe database");
}
}
},
DbAction::Vacuum => {
match db.vacuum() {
Ok(()) => {
log::info!("database optimized successfully");
},
Err(e) => {
log::error!("failed to vacuum database: {e}");
},
}
},
DbAction::Stats => {
match db.stats() {
Ok(stats) => {
println!("{stats}");
},
Err(e) => {
log::error!("failed to get database stats: {e}");
},
}
},
}
},
Some(Command::Import { r#type, ask }) => {
let mut should_proceed = true;
if ask {
should_proceed = confirm(
"Are you sure you want to import clipboard data? This may \
overwrite existing entries.",
);
if !should_proceed {
log::info!("import command aborted by user.");
}
}
if should_proceed {
let format = r#type.as_deref().unwrap_or("tsv");
match format {
"tsv" => {
if let Err(e) =
ImportCommand::import_tsv(&db, io::stdin(), cli.max_items)
{
log::error!("failed to import TSV: {e}");
}
},
_ => {
log::error!("unsupported import format: {format}");
},
}
}
},
Some(Command::Watch {
expire_after,
mime_type,
persist,
}) => {
db.watch(
cli.max_dedupe_search,
cli.max_items,
#[cfg(feature = "use-toplevel")]
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
expire_after,
&mime_type,
cli.min_size,
cli.max_size,
persist,
)
.await;
},
None => {
Cli::command().print_help()?;
println!();
},
}
Ok(())
})
}

273
src/mime.rs Normal file
View file

@ -0,0 +1,273 @@
use imagesize::ImageType;
/// Detect MIME type of clipboard data. We try binary detection first using
/// [`imagesize`] followed by a check for text/uri-list for file manager copies
/// and finally fall back to text/plain for UTF-8 or [`None`] for binary.
pub fn detect_mime(data: &[u8]) -> Option<String> {
if data.is_empty() {
return None;
}
// Try image detection first
if let Ok(img_type) = imagesize::image_type(data) {
return Some(image_type_to_mime(img_type));
}
// Check if it's UTF-8 text
if let Ok(text) = std::str::from_utf8(data) {
let trimmed = text.trim();
// Check for text/uri-list format (file paths from file managers)
if is_uri_list(trimmed) {
return Some("text/uri-list".to_string());
}
// Default to plain text
return Some("text/plain".to_string());
}
// Unknown binary data
None
}
/// Convert [`imagesize`] [`ImageType`] to MIME type string
fn image_type_to_mime(img_type: ImageType) -> String {
let mime = match img_type {
ImageType::Png => "image/png",
ImageType::Jpeg => "image/jpeg",
ImageType::Gif => "image/gif",
ImageType::Bmp => "image/bmp",
ImageType::Tiff => "image/tiff",
ImageType::Webp => "image/webp",
ImageType::Aseprite => "image/x-aseprite",
ImageType::Dds => "image/vnd.ms-dds",
ImageType::Exr => "image/aces",
ImageType::Farbfeld => "image/farbfeld",
ImageType::Hdr => "image/vnd.radiance",
ImageType::Ico => "image/x-icon",
ImageType::Ilbm => "image/ilbm",
ImageType::Jxl => "image/jxl",
ImageType::Ktx2 => "image/ktx2",
ImageType::Pnm => "image/x-portable-anymap",
ImageType::Psd => "image/vnd.adobe.photoshop",
ImageType::Qoi => "image/qoi",
ImageType::Tga => "image/x-tga",
ImageType::Vtf => "image/x-vtf",
ImageType::Heif(imagesize::Compression::Hevc) => "image/heic",
ImageType::Heif(_) => "image/heif",
_ => "application/octet-stream",
};
mime.to_string()
}
/// Check if text is a URI list per RFC 2483.
///
/// Used when copying files from file managers - they provide file paths
/// as text/uri-list format (`file://` URIs, one per line, `#` for comments).
fn is_uri_list(text: &str) -> bool {
if text.is_empty() {
return false;
}
// Must start with a URI scheme to even consider it
if !text.starts_with("file://")
&& !text.starts_with("http://")
&& !text.starts_with("https://")
&& !text.starts_with("ftp://")
&& !text.starts_with('#')
{
return false;
}
let lines: Vec<&str> = text.lines().map(str::trim).collect();
// Check first non-comment line is a URI
let first_content =
lines.iter().find(|l| !l.is_empty() && !l.starts_with('#'));
if let Some(line) = first_content {
line.starts_with("file://")
|| line.starts_with("http://")
|| line.starts_with("https://")
|| line.starts_with("ftp://")
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_data() {
assert_eq!(detect_mime(b""), None);
}
#[test]
fn test_plain_text() {
let data = b"Hello, world!";
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
}
#[test]
fn test_uri_list_single_file() {
let data = b"file:///home/user/document.pdf";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_uri_list_multiple_files() {
let data = b"file:///home/user/file1.txt\nfile:///home/user/file2.txt";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_uri_list_with_comments() {
let data = b"# Comment\nfile:///home/user/file.txt";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_uri_list_http() {
let data = b"https://example.com/image.png";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_not_uri_list() {
let data = b"This is just text with file:// in the middle";
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
}
#[test]
fn test_unknown_binary() {
// Binary data that's not UTF-8 and not a known format
let data = b"\x80\x81\x82\x83\x84\x85\x86\x87";
assert_eq!(detect_mime(data), None);
}
#[test]
fn test_uri_list_trailing_newline() {
let data = b"file:///foo\n";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_uri_list_ftp() {
let data = b"ftp://host/path";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_uri_list_mixed_schemes() {
let data = b"file:///home/user/doc.pdf\nhttps://example.com/file.zip";
assert_eq!(detect_mime(data), Some("text/uri-list".to_string()));
}
#[test]
fn test_plain_url_in_text() {
let data = b"visit http://example.com for info";
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
}
#[test]
fn test_png_magic_bytes() {
// Real PNG header: 8-byte signature + minimal IHDR chunk
let data: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, // IHDR chunk length
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x00, 0x01, // width: 1
0x00, 0x00, 0x00, 0x01, // height: 1
0x08, 0x02, // bit depth: 8, color type: 2 (RGB)
0x00, 0x00, 0x00, // compression, filter, interlace
0x90, 0x77, 0x53, 0xDE, // CRC
];
assert_eq!(detect_mime(data), Some("image/png".to_string()));
}
#[test]
fn test_jpeg_magic_bytes() {
// JPEG SOI marker + APP0 (JFIF) marker
let data: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xE0, // SOI + APP0
0x00, 0x10, // Length
0x4A, 0x46, 0x49, 0x46, 0x00, // "JFIF\0"
0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
];
assert_eq!(detect_mime(data), Some("image/jpeg".to_string()));
}
#[test]
fn test_gif_magic_bytes() {
// GIF89a header
let data: &[u8] = &[
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // "GIF89a"
0x01, 0x00, 0x01, 0x00, // 1x1
0x80, 0x00, 0x00, // GCT flag, bg, aspect
];
assert_eq!(detect_mime(data), Some("image/gif".to_string()));
}
#[test]
fn test_webp_magic_bytes() {
// RIFF....WEBP header
let data: &[u8] = &[
0x52, 0x49, 0x46, 0x46, // "RIFF"
0x24, 0x00, 0x00, 0x00, // file size
0x57, 0x45, 0x42, 0x50, // "WEBP"
0x56, 0x50, 0x38, 0x20, // "VP8 "
0x18, 0x00, 0x00, 0x00, // chunk size
0x30, 0x01, 0x00, 0x9D, 0x01, 0x2A, // VP8 bitstream
0x01, 0x00, 0x01, 0x00, // width/height
];
assert_eq!(detect_mime(data), Some("image/webp".to_string()));
}
#[test]
fn test_whitespace_only() {
let data = b" \n\t ";
// Valid UTF-8 text, even if only whitespace. [`detect_mime`] doesn't reject
// it (store_entry rejects it separately). As text it's text/plain.
assert_eq!(detect_mime(data), Some("text/plain".to_string()));
}
#[test]
fn test_image_type_to_mime_coverage() {
assert_eq!(image_type_to_mime(ImageType::Png), "image/png");
assert_eq!(image_type_to_mime(ImageType::Jpeg), "image/jpeg");
assert_eq!(image_type_to_mime(ImageType::Gif), "image/gif");
assert_eq!(image_type_to_mime(ImageType::Bmp), "image/bmp");
assert_eq!(image_type_to_mime(ImageType::Tiff), "image/tiff");
assert_eq!(image_type_to_mime(ImageType::Webp), "image/webp");
assert_eq!(image_type_to_mime(ImageType::Aseprite), "image/x-aseprite");
assert_eq!(image_type_to_mime(ImageType::Dds), "image/vnd.ms-dds");
assert_eq!(image_type_to_mime(ImageType::Exr), "image/aces");
assert_eq!(image_type_to_mime(ImageType::Farbfeld), "image/farbfeld");
assert_eq!(image_type_to_mime(ImageType::Hdr), "image/vnd.radiance");
assert_eq!(image_type_to_mime(ImageType::Ico), "image/x-icon");
assert_eq!(image_type_to_mime(ImageType::Ilbm), "image/ilbm");
assert_eq!(image_type_to_mime(ImageType::Jxl), "image/jxl");
assert_eq!(image_type_to_mime(ImageType::Ktx2), "image/ktx2");
assert_eq!(
image_type_to_mime(ImageType::Pnm),
"image/x-portable-anymap"
);
assert_eq!(
image_type_to_mime(ImageType::Psd),
"image/vnd.adobe.photoshop"
);
assert_eq!(image_type_to_mime(ImageType::Qoi), "image/qoi");
assert_eq!(image_type_to_mime(ImageType::Tga), "image/x-tga");
assert_eq!(image_type_to_mime(ImageType::Vtf), "image/x-vtf");
assert_eq!(
image_type_to_mime(ImageType::Heif(imagesize::Compression::Hevc)),
"image/heic"
);
assert_eq!(
image_type_to_mime(ImageType::Heif(imagesize::Compression::Av1)),
"image/heif"
);
}
}

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;

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

@ -0,0 +1,296 @@
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);
}
const 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 proper Unix fork() to create a child process that continues
// serving clipboard content after parent exits.
// XXX: I wanted to choose and approach without fork, but we could not
// ensure persistence after the thread dies. Alas, we gotta fork.
unsafe {
match libc::fork() {
0 => {
// Child process - serve clipboard content
if let Err(e) = prepared_copy.serve() {
log::error!("background clipboard service failed: {e}");
std::process::exit(1);
}
std::process::exit(0);
},
-1 => {
// Fork failed
log::error!("failed to fork background process");
std::process::exit(1);
},
_ => {
// Parent process - exit immediately
log::debug!("forked background process to serve clipboard content");
std::process::exit(0);
},
}
}
}
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 content and serve in current process
// Use prepare_copy + serve to ensure proper clipboard registration
let mut opts_fg = opts;
opts_fg.foreground(true);
let prepared_copy = opts_fg
.prepare_copy(Source::Bytes(input.into()), mime_type)
.context("failed to prepare copy")?;
// Serve in foreground - blocks until interrupted (Ctrl+C, etc.)
prepared_copy
.serve()
.context("failed to serve clipboard content")?;
} else {
// Background mode: spawn child process to serve requests
// First prepare to copy to validate before spawning
let mut opts_fg = opts;
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(())
}

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

@ -0,0 +1,531 @@
// 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(())
}
/// Select the best MIME type from available types when none is specified.
/// Prefers specific content types (image/*, application/*) over generic
/// text representations (TEXT, STRING, `UTF8_STRING`).
fn select_best_mime_type(
types: &std::collections::HashSet<String>,
) -> Option<String> {
if types.is_empty() {
return None;
}
// If only one type available, use it
if types.len() == 1 {
return types.iter().next().cloned();
}
// Prefer specific MIME types with slashes (e.g., image/png, application/pdf)
// over generic X11 selections (TEXT, STRING, UTF8_STRING)
let specific_types: Vec<_> =
types.iter().filter(|t| t.contains('/')).collect();
if !specific_types.is_empty() {
// Among specific types, prefer non-text types first
for mime in &specific_types {
if !mime.starts_with("text/") {
return Some((*mime).clone());
}
}
// If all are text types, prefer text/plain with charset
for mime in &specific_types {
if mime.starts_with("text/plain;charset=") {
return Some((*mime).clone());
}
}
// Otherwise return first specific type
return Some(specific_types[0].clone());
}
// Fall back to generic text selections in order of preference
for fallback in &["UTF8_STRING", "STRING", "TEXT"] {
if types.contains(*fallback) {
return Some((*fallback).to_string());
}
}
// Last resort: return any available type
types.iter().next().cloned()
}
fn handle_regular_paste(
args: &WlPasteArgs,
clipboard: PasteClipboardType,
seat: PasteSeat,
) -> Result<()> {
// If no MIME type specified, select the best available MIME type
let available_types = if args.mime_type.is_none() {
get_mime_types(clipboard, seat).ok()
} else {
None
};
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
let mime_type = if let Some(ref best) = selected_type {
log::debug!("auto-selecting MIME type: {best}");
PasteMimeType::Specific(best)
} else {
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}");
}
// Only add newline for text content, not binary data
// Check if the MIME type indicates text content
let is_text_content = if types.is_empty() {
// If no MIME type, check if content is valid UTF-8
std::str::from_utf8(&buf).is_ok()
} else {
types.starts_with("text/")
|| types == "application/json"
|| types == "application/xml"
|| types == "application/x-sh"
};
if !args.no_newline
&& is_text_content
&& !buf.ends_with(b"\n")
&& 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(())
}

179
src/wayland/mod.rs Normal file
View file

@ -0,0 +1,179 @@
use std::{
collections::HashMap,
sync::{Arc, LazyLock, Mutex},
};
use arc_swap::ArcSwapOption;
use log::debug;
use wayland_client::{
Connection as WaylandConnection,
Dispatch,
Proxy,
QueueHandle,
backend::ObjectId,
protocol::wl_registry,
};
use wayland_protocols_wlr::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1},
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
};
static FOCUSED_APP: ArcSwapOption<String> = ArcSwapOption::const_empty();
static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Initialize Wayland state for window management in a background thread
pub fn init_wayland_state() {
std::thread::spawn(|| {
if let Err(e) = run_wayland_event_loop() {
debug!("Wayland event loop error: {e}");
}
});
}
/// Get the currently focused window application name using Wayland protocols
pub fn get_focused_window_app() -> Option<String> {
// Load the focused app using lock-free arc-swap
let focused = FOCUSED_APP.load();
if let Some(app) = focused.as_ref() {
debug!("Found focused app via Wayland protocol: {app}");
return Some(app.to_string());
}
debug!("No focused window detection method worked");
None
}
/// Run the Wayland event loop
fn run_wayland_event_loop() -> Result<(), Box<dyn std::error::Error>> {
let conn = match WaylandConnection::connect_to_env() {
Ok(conn) => conn,
Err(e) => {
debug!("Failed to connect to Wayland: {e}");
return Ok(());
},
};
let display = conn.display();
let mut event_queue = conn.new_event_queue();
let qh = event_queue.handle();
let _registry = display.get_registry(&qh, ());
loop {
event_queue.blocking_dispatch(&mut AppState)?;
}
}
struct AppState;
impl Dispatch<wl_registry::WlRegistry, ()> for AppState {
fn event(
_state: &mut Self,
registry: &wl_registry::WlRegistry,
event: wl_registry::Event,
_data: &(),
_conn: &WaylandConnection,
qh: &QueueHandle<Self>,
) {
if let wl_registry::Event::Global {
name,
interface,
version: _,
} = event
&& interface == "zwlr_foreign_toplevel_manager_v1"
{
let _manager: ZwlrForeignToplevelManagerV1 =
registry.bind(name, 1, qh, ());
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelManagerV1, ()>(())
}
}
impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for AppState {
fn event(
_state: &mut Self,
_manager: &ZwlrForeignToplevelManagerV1,
event: zwlr_foreign_toplevel_manager_v1::Event,
_data: &(),
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
) {
if let zwlr_foreign_toplevel_manager_v1::Event::Toplevel { toplevel } =
event
{
// New toplevel created
// We'll track it for focus events
let _: ZwlrForeignToplevelHandleV1 = toplevel;
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
}
}
impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
fn event(
_state: &mut Self,
handle: &ZwlrForeignToplevelHandleV1,
event: zwlr_foreign_toplevel_handle_v1::Event,
_data: &(),
_conn: &WaylandConnection,
_qh: &QueueHandle<Self>,
) {
let handle_id = handle.id();
match event {
zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => {
debug!("Toplevel app_id: {app_id}");
// Store the app_id for this handle
if let Ok(mut apps) = TOPLEVEL_APPS.lock() {
apps.insert(handle_id, app_id);
}
},
zwlr_foreign_toplevel_handle_v1::Event::State {
state: toplevel_state,
} => {
// Check if this toplevel is activated (focused)
let states: Vec<u8> = toplevel_state;
// Check for activated state (value 2 in the enum)
if states.chunks_exact(4).any(|chunk| {
u32::from_ne_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]) == 2
}) {
debug!("Toplevel activated");
// Update focused app to the `app_id` of this handle
if let Ok(apps) = TOPLEVEL_APPS.lock()
&& let Some(app_id) = apps.get(&handle_id)
{
debug!("Setting focused app to: {app_id}");
FOCUSED_APP.store(Some(Arc::new(app_id.clone())));
}
}
},
zwlr_foreign_toplevel_handle_v1::Event::Closed => {
// Clean up when toplevel is closed
if let Ok(mut apps) = TOPLEVEL_APPS.lock() {
apps.remove(&handle_id);
}
},
_ => {},
}
}
fn event_created_child(
_opcode: u16,
qhandle: &QueueHandle<Self>,
) -> std::sync::Arc<dyn wayland_client::backend::ObjectData> {
qhandle.make_data::<ZwlrForeignToplevelHandleV1, ()>(())
}
}